From 382a01568814276e1c1b5f203f5f9a0bdcceab6f Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 10 Jul 2026 12:25:33 +0200 Subject: [PATCH] test(core): organize hardening coverage and CI --- .github/workflows/rust-core.yml | 37 ++ crates/vnidrop/src/tests.rs | 424 +---------------- crates/vnidrop/src/tests/access_policy.rs | 56 +++ crates/vnidrop/src/tests/filesystem.rs | 233 ++++++++++ crates/vnidrop/src/tests/handshake.rs | 13 + crates/vnidrop/src/tests/limits.rs | 15 + crates/vnidrop/src/tests/repository.rs | 516 +++++++++++++++++++++ crates/vnidrop/src/tests/runtime.rs | 143 ++++++ crates/vnidrop/src/tests/secret.rs | 22 + crates/vnidrop/src/tests/ticket.rs | 134 ++++++ crates/vnidrop/src/tests/transfer_state.rs | 31 ++ crates/vnidrop/tests/README.md | 44 ++ crates/vnidrop/tests/approval.rs | 130 ++++++ crates/vnidrop/tests/lifecycle.rs | 251 ++++++++++ crates/vnidrop/tests/local_transfer.rs | 496 -------------------- crates/vnidrop/tests/output_sink.rs | 104 +++++ crates/vnidrop/tests/support/mod.rs | 262 +++++++++++ crates/vnidrop/tests/transfer.rs | 104 +++++ 18 files changed, 2113 insertions(+), 902 deletions(-) create mode 100644 .github/workflows/rust-core.yml create mode 100644 crates/vnidrop/src/tests/access_policy.rs create mode 100644 crates/vnidrop/src/tests/filesystem.rs create mode 100644 crates/vnidrop/src/tests/handshake.rs create mode 100644 crates/vnidrop/src/tests/limits.rs create mode 100644 crates/vnidrop/src/tests/repository.rs create mode 100644 crates/vnidrop/src/tests/runtime.rs create mode 100644 crates/vnidrop/src/tests/secret.rs create mode 100644 crates/vnidrop/src/tests/ticket.rs create mode 100644 crates/vnidrop/src/tests/transfer_state.rs create mode 100644 crates/vnidrop/tests/README.md create mode 100644 crates/vnidrop/tests/approval.rs create mode 100644 crates/vnidrop/tests/lifecycle.rs delete mode 100644 crates/vnidrop/tests/local_transfer.rs create mode 100644 crates/vnidrop/tests/output_sink.rs create mode 100644 crates/vnidrop/tests/support/mod.rs create mode 100644 crates/vnidrop/tests/transfer.rs diff --git a/.github/workflows/rust-core.yml b/.github/workflows/rust-core.yml new file mode 100644 index 0000000..192b88b --- /dev/null +++ b/.github/workflows/rust-core.yml @@ -0,0 +1,37 @@ +name: Rust core + +on: + pull_request: + paths: + - "Cargo.toml" + - "Cargo.lock" + - "crates/vnidrop/**" + - ".github/workflows/rust-core.yml" + push: + paths: + - "Cargo.toml" + - "Cargo.lock" + - "crates/vnidrop/**" + - ".github/workflows/rust-core.yml" + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Install Rust quality components + run: rustup component add clippy rustfmt + - name: Check formatting + run: cargo fmt --all -- --check + - name: Run strict Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Run unit and integration tests + run: cargo test --workspace --all-targets + - name: Check documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --workspace --no-deps diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 9672cd9..ba9f72a 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -1,406 +1,18 @@ -#[cfg(test)] -mod tests { - #[cfg(unix)] - use std::os::fd::AsRawFd; - use std::{io::Read, path::Path, sync::Arc}; - - use data_encoding::BASE64URL_NOPAD; - use iroh::SecretKey; - use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; - use serde_json::json; - - use crate::{ - access_policy::{AccessDecision, AccessPolicy}, - api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata}, - error::VnidropError, - filesystem::{ - collect_import_files, default_collection_name, path_to_string, - percent_decode_file_url_path, validated_relative_string, - }, - repository::{ReceiverRequestInsert, Repository}, - runtime::VnidropCore, - secret::load_or_create_secret, - ticket::{parse_transfer_ticket, VnidropTicket}, - TransferAccessMode, - }; - - struct TestSink; - - impl CoreEventSink for TestSink { - fn on_event(&self, _event: CoreEvent) {} - } - - #[test] - fn metadata_ticket_round_trips() { - let secret = SecretKey::generate(); - let addr = iroh::EndpointAddr::new(secret.public()); - let blob_ticket = BlobTicket::new(addr, Hash::new([7; 32]), BlobFormat::HashSeq); - let metadata = TransferMetadata::new( - 42, - "Summer photos", - Some("hammed".to_string()), - blob_ticket.hash(), - 3, - 2048, - ); - let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone()) - .encode() - .unwrap(); - let parsed = parse_transfer_ticket(&encoded).unwrap(); - - assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); - assert_eq!( - parsed.metadata.unwrap().transfer_name, - metadata.transfer_name - ); - } - - #[test] - fn metadata_ticket_round_trip_tolerates_wrapped_whitespace() { - let secret = SecretKey::generate(); - let addr = iroh::EndpointAddr::new(secret.public()); - let blob_ticket = BlobTicket::new(addr, Hash::new([9; 32]), BlobFormat::HashSeq); - let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10); - let encoded = VnidropTicket::new(blob_ticket.clone(), metadata) - .encode() - .unwrap(); - let wrapped = encoded - .as_bytes() - .chunks(8) - .map(|chunk| std::str::from_utf8(chunk).unwrap()) - .collect::>() - .join("\n "); - - let parsed = parse_transfer_ticket(&wrapped).unwrap(); - assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); - } - - #[test] - fn invalid_ticket_is_rejected() { - assert!(parse_transfer_ticket("not-a-ticket").is_err()); - } - - #[test] - fn ticket_rejects_unsupported_versions_and_mismatched_hashes() { - let secret = SecretKey::generate(); - let addr = iroh::EndpointAddr::new(secret.public()); - let blob_ticket = BlobTicket::new(addr, Hash::new([5; 32]), BlobFormat::HashSeq); - let payload = json!({ - "version": 2, - "blob_ticket": blob_ticket.to_string(), - "metadata": { - "version": 1, - "transfer_id": 7, - "transfer_name": "bad version", - "sender_name": null, - "created_at": 1, - "content_hash": blob_ticket.hash().to_string(), - "file_count": 1, - "total_size": 10 - } - }); - let encoded = format!( - "vnd1:{}", - BASE64URL_NOPAD.encode(payload.to_string().as_bytes()) - ); - assert!(parse_transfer_ticket(&encoded) - .unwrap_err() - .to_string() - .contains("unsupported VniDrop ticket version")); - - let payload = json!({ - "version": 1, - "blob_ticket": blob_ticket.to_string(), - "metadata": { - "version": 1, - "transfer_id": 7, - "transfer_name": "bad hash", - "sender_name": null, - "created_at": 1, - "content_hash": Hash::new([6; 32]).to_string(), - "file_count": 1, - "total_size": 10 - } - }); - let encoded = format!( - "vnd1:{}", - BASE64URL_NOPAD.encode(payload.to_string().as_bytes()) - ); - assert!(parse_transfer_ticket(&encoded) - .unwrap_err() - .to_string() - .contains("metadata hash does not match")); - } - - #[tokio::test] - async fn secret_persists() { - let temp = tempfile::tempdir().unwrap(); - let first = load_or_create_secret(temp.path()).await.unwrap(); - let second = load_or_create_secret(temp.path()).await.unwrap(); - assert_eq!(first.to_bytes(), second.to_bytes()); - } - - #[test] - fn path_validation_rejects_unsafe_paths() { - assert!(path_to_string(Path::new("../escape"), true).is_err()); - assert!(path_to_string(Path::new("/absolute"), true).is_err()); - assert!(validated_relative_string("bad\\name").is_err()); - assert!(validated_relative_string("").is_err()); - } - - #[test] - fn file_url_decodes_spaces() { - assert_eq!( - percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(), - "/tmp/My File.txt" - ); - } - - #[cfg(unix)] - #[test] - fn file_descriptor_source_duplicates_and_streams() { - let mut temp = tempfile::tempfile().unwrap(); - std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap(); - std::io::Seek::rewind(&mut temp).unwrap(); - - let files = collect_import_files(vec![ShareSource { - kind: SourceKind::FileDescriptor, - value: temp.as_raw_fd().to_string(), - display_name: Some("from-fd.txt".to_string()), - is_directory: false, - }]) - .unwrap(); - - let mut imported = files.into_iter().next().unwrap().source.open().unwrap(); - let mut content = String::new(); - imported.read_to_string(&mut content).unwrap(); - assert_eq!(content, "fd-backed import"); - } - - #[cfg(unix)] - #[test] - fn file_descriptor_source_rejects_invalid_values() { - assert!(collect_import_files(vec![ShareSource { - kind: SourceKind::FileDescriptor, - value: "not-an-fd".to_string(), - display_name: Some("from-fd.txt".to_string()), - is_directory: false, - }]) - .is_err()); - - assert!(collect_import_files(vec![ShareSource { - kind: SourceKind::FileDescriptor, - value: "-1".to_string(), - display_name: Some("from-fd.txt".to_string()), - is_directory: false, - }]) - .is_err()); - } - - #[test] - fn android_content_uri_must_be_opened_by_platform_code() { - let error = collect_import_files(vec![ShareSource { - kind: SourceKind::AndroidContentUri, - value: "content://media/item".to_string(), - display_name: Some("from-uri.txt".to_string()), - is_directory: false, - }]) - .unwrap_err() - .to_string(); - assert!(error.contains("ParcelFileDescriptor")); - } - - #[test] - fn directory_sources_preserve_safe_relative_names() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("picked"); - std::fs::create_dir_all(root.join("nested")).unwrap(); - std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap(); - std::fs::write(root.join("b.txt"), b"b").unwrap(); - - let mut files = collect_import_files(vec![ShareSource { - kind: SourceKind::Path, - value: root.to_string_lossy().to_string(), - display_name: Some("Album".to_string()), - is_directory: true, - }]) - .unwrap(); - files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name)); - - assert_eq!(default_collection_name(&files), "Album"); - assert_eq!(files[0].collection_name, "Album/b.txt"); - assert_eq!(files[1].collection_name, "Album/nested/a.txt"); - } - - #[test] - fn can_initialize_core() { - let temp = tempfile::tempdir().unwrap(); - let core = VnidropCore::initialize( - temp.path().to_string_lossy().to_string(), - Arc::new(TestSink), - ) - .unwrap(); - let status = core.status(); - assert!(!status.endpoint_id.is_empty()); - core.shutdown(); - } - - #[tokio::test] - async fn repository_persists_transfers_and_events() { - let temp = tempfile::tempdir().unwrap(); - let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 1); - repository - .upsert_transfer(crate::repository::TransferUpsert { - transfer_id: 7, - direction: "send", - status: "sharing", - transfer_name: Some("demo"), - content_hash: Some("hash"), - ticket: Some("ticket"), - file_count: 1, - total_size: 12, - }) - .await - .unwrap(); - repository - .insert_event(&CoreEvent { - id: "event-1".to_string(), - timestamp: 10, - scope: "transfer".to_string(), - transfer_id: Some(7), - direction: Some("send".to_string()), - phase: "ticket".to_string(), - kind: "created".to_string(), - data_json: "{}".to_string(), - }) - .await - .unwrap(); - - let transfers = repository.list_transfers().await.unwrap(); - assert_eq!(transfers.len(), 1); - assert_eq!(transfers[0].transfer_name.as_deref(), Some("demo")); - - let events = repository.list_events(Some(7)).await.unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].kind, "created"); - - let reopened = Repository::open(temp.path()).await.unwrap(); - let transfers = reopened.list_transfers().await.unwrap(); - assert_eq!(transfers.len(), 1); - let events = reopened.list_events(Some(7)).await.unwrap(); - assert_eq!(events[0].id, "event-1"); - } - - #[tokio::test] - async fn repository_persists_receiver_requests() { - let temp = tempfile::tempdir().unwrap(); - let repository = Repository::open(temp.path()).await.unwrap(); - repository - .insert_receiver_request(ReceiverRequestInsert { - id: "request-1", - transfer_id: 77, - remote_endpoint_id: "node-a", - transfer_name: "demo", - receiver_name: Some("receiver"), - receiver_device_name: Some("phone"), - app_version: "0.1.0", - }) - .await - .unwrap(); - repository - .update_receiver_request_status("request-1", "accepted", None) - .await - .unwrap(); - assert!(repository - .update_receiver_request_status("request-1", "refused", Some("late")) - .await - .is_err()); - assert!(repository - .update_receiver_request_status("missing", "accepted", None) - .await - .is_err()); - - let requests = repository.list_receiver_requests(77).await.unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].status, "accepted"); - assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver")); - assert!(requests[0].responded_at.is_some()); - } - - #[tokio::test] - async fn access_policy_requires_approved_endpoint_when_locked() { - let policy = AccessPolicy::new(); - policy - .set_mode(99, TransferAccessMode::ApprovalRequired) - .await; - - assert_eq!( - policy.decide(99, Some("node-a")).await, - AccessDecision::Deny { - reason: "approval-required" - } - ); - - policy.approve_endpoint(99, "node-a".to_string()).await; - assert_eq!( - policy.decide(99, Some("node-a")).await, - AccessDecision::Allow - ); - assert_eq!( - policy.decide(99, None).await, - AccessDecision::Deny { - reason: "missing-endpoint-id" - } - ); - } - - #[tokio::test] - async fn access_policy_rejects_expired_approval_sessions() { - let policy = AccessPolicy::new(); - policy - .set_mode(100, TransferAccessMode::ApprovalRequired) - .await; - policy - .approve_endpoint_until(100, "node-a".to_string(), Some(crate::util::now_ms() - 1)) - .await; - - assert_eq!( - policy.decide(100, Some("node-a")).await, - AccessDecision::Deny { - reason: "approval-expired" - } - ); - assert_eq!( - policy.decide(100, Some("node-a")).await, - AccessDecision::Deny { - reason: "approval-required" - } - ); - } - - #[test] - fn invalid_receive_ticket_is_typed_and_persisted_as_event() { - let temp = tempfile::tempdir().unwrap(); - let core = VnidropCore::initialize( - temp.path().to_string_lossy().to_string(), - Arc::new(TestSink), - ) - .unwrap(); - - let error = core - .receive( - "not-a-ticket".to_string(), - temp.path().to_string_lossy().to_string(), - None, - ) - .unwrap_err(); - assert!(matches!(error, VnidropError::Ticket { .. })); - - let events = core.list_events(None).unwrap(); - assert!(events - .iter() - .any(|event| event.phase == "error" && event.kind == "invalid-ticket")); - core.shutdown(); - } -} +#[path = "tests/access_policy.rs"] +mod access_policy_tests; +#[path = "tests/filesystem.rs"] +mod filesystem_tests; +#[path = "tests/handshake.rs"] +mod handshake_tests; +#[path = "tests/limits.rs"] +mod limits_tests; +#[path = "tests/repository.rs"] +mod repository_tests; +#[path = "tests/runtime.rs"] +mod runtime_tests; +#[path = "tests/secret.rs"] +mod secret_tests; +#[path = "tests/ticket.rs"] +mod ticket_tests; +#[path = "tests/transfer_state.rs"] +mod transfer_state_tests; diff --git a/crates/vnidrop/src/tests/access_policy.rs b/crates/vnidrop/src/tests/access_policy.rs new file mode 100644 index 0000000..4aa6fcc --- /dev/null +++ b/crates/vnidrop/src/tests/access_policy.rs @@ -0,0 +1,56 @@ +use crate::{ + access_policy::{AccessDecision, AccessPolicy}, + util::now_ms, + TransferAccessMode, +}; + +#[tokio::test] +async fn requires_approved_endpoint_when_locked() { + let policy = AccessPolicy::new(); + policy + .set_mode(99, TransferAccessMode::ApprovalRequired) + .await; + + assert_eq!( + policy.decide(99, Some("node-a")).await, + AccessDecision::Deny { + reason: "approval-required" + } + ); + + policy.approve_endpoint(99, "node-a".to_string()).await; + assert_eq!( + policy.decide(99, Some("node-a")).await, + AccessDecision::Allow + ); + assert_eq!( + policy.decide(99, None).await, + AccessDecision::Deny { + reason: "missing-endpoint-id" + } + ); +} + +#[tokio::test] +async fn rejects_expired_approval_sessions() { + let policy = AccessPolicy::new(); + policy + .set_mode(100, TransferAccessMode::ApprovalRequired) + .await; + policy + .approve_endpoint_until(100, "node-a".to_string(), Some(now_ms() - 1)) + .await; + + assert_eq!( + policy.decide(100, Some("node-a")).await, + AccessDecision::Deny { + reason: "approval-expired" + } + ); + assert_eq!( + policy.decide(100, Some("node-a")).await, + AccessDecision::Deny { + reason: "approval-required" + } + ); +} diff --git a/crates/vnidrop/src/tests/filesystem.rs b/crates/vnidrop/src/tests/filesystem.rs new file mode 100644 index 0000000..38c9937 --- /dev/null +++ b/crates/vnidrop/src/tests/filesystem.rs @@ -0,0 +1,233 @@ +#[cfg(unix)] +use std::os::fd::AsRawFd; +use std::{io::Read, path::Path}; + +use crate::{ + api::{CoreLimits, ShareSource, SourceKind}, + filesystem::{ + cleanup_stale_temporary_files, collect_import_files, collect_import_files_with_limits, + default_collection_name, path_to_string, percent_decode_file_url_path, + validated_relative_string, AtomicOutputFile, + }, +}; + +#[test] +fn path_validation_rejects_unsafe_paths() { + assert!(path_to_string(Path::new("../escape"), true).is_err()); + assert!(path_to_string(Path::new("/absolute"), true).is_err()); + assert!(validated_relative_string("bad\\name").is_err()); + assert!(validated_relative_string("").is_err()); +} + +#[test] +fn file_url_decodes_spaces() { + assert_eq!( + percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(), + "/tmp/My File.txt" + ); +} + +#[cfg(unix)] +#[test] +fn file_descriptor_source_duplicates_and_streams() { + let mut temp = tempfile::tempfile().unwrap(); + std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap(); + std::io::Seek::rewind(&mut temp).unwrap(); + + let files = collect_import_files(vec![ShareSource { + kind: SourceKind::FileDescriptor, + value: temp.as_raw_fd().to_string(), + display_name: Some("from-fd.txt".to_string()), + is_directory: false, + }]) + .unwrap(); + + let mut imported = files.into_iter().next().unwrap().source.open().unwrap(); + let mut content = String::new(); + imported.read_to_string(&mut content).unwrap(); + assert_eq!(content, "fd-backed import"); +} + +#[cfg(unix)] +#[test] +fn file_descriptor_source_rejects_invalid_values() { + for value in ["not-an-fd", "-1"] { + assert!(collect_import_files(vec![ShareSource { + kind: SourceKind::FileDescriptor, + value: value.to_string(), + display_name: Some("from-fd.txt".to_string()), + is_directory: false, + }]) + .is_err()); + } +} + +#[test] +fn android_content_uri_must_be_opened_by_platform_code() { + let error = collect_import_files(vec![ShareSource { + kind: SourceKind::AndroidContentUri, + value: "content://media/item".to_string(), + display_name: Some("from-uri.txt".to_string()), + is_directory: false, + }]) + .unwrap_err() + .to_string(); + assert!(error.contains("ParcelFileDescriptor")); +} + +#[test] +fn directory_sources_preserve_safe_relative_names() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("picked"); + std::fs::create_dir_all(root.join("nested")).unwrap(); + std::fs::write(root.join("nested").join("a.txt"), b"a").unwrap(); + std::fs::write(root.join("b.txt"), b"b").unwrap(); + + let mut files = collect_import_files(vec![ShareSource { + kind: SourceKind::Path, + value: root.to_string_lossy().to_string(), + display_name: Some("Album".to_string()), + is_directory: true, + }]) + .unwrap(); + files.sort_by(|a, b| a.collection_name.cmp(&b.collection_name)); + + assert_eq!(default_collection_name(&files), "Album"); + assert_eq!(files[0].collection_name, "Album/b.txt"); + assert_eq!(files[1].collection_name, "Album/nested/a.txt"); +} + +#[test] +fn atomic_output_commits_without_overwriting() { + let output = tempfile::tempdir().unwrap(); + let (pending, mut file) = AtomicOutputFile::create(output.path(), "nested/file.txt").unwrap(); + std::io::Write::write_all(&mut file, b"complete").unwrap(); + file.sync_all().unwrap(); + drop(file); + pending.commit().unwrap(); + + assert_eq!( + std::fs::read(output.path().join("nested/file.txt")).unwrap(), + b"complete" + ); + assert!(AtomicOutputFile::create(output.path(), "nested/file.txt").is_err()); + assert_eq!( + std::fs::read(output.path().join("nested/file.txt")).unwrap(), + b"complete" + ); +} + +#[test] +fn dropped_atomic_output_removes_partial_file() { + let output = tempfile::tempdir().unwrap(); + let (pending, mut file) = AtomicOutputFile::create(output.path(), "partial.txt").unwrap(); + std::io::Write::write_all(&mut file, b"partial").unwrap(); + drop(file); + drop(pending); + + assert!(!output.path().join("partial.txt").exists()); + assert!(std::fs::read_dir(output.path()).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".part"))); +} + +#[cfg(unix)] +#[test] +fn atomic_output_rejects_symlinked_parent() { + use std::os::unix::fs::symlink; + + let output = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), output.path().join("link")).unwrap(); + + assert!(AtomicOutputFile::create(output.path(), "link/escape.txt").is_err()); + assert!(!outside.path().join("escape.txt").exists()); +} + +#[cfg(unix)] +#[test] +fn atomic_output_never_replaces_symlink_destination() { + use std::os::unix::fs::symlink; + + let output = tempfile::tempdir().unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(outside.path(), b"outside").unwrap(); + symlink(outside.path(), output.path().join("target.txt")).unwrap(); + + assert!(AtomicOutputFile::create(output.path(), "target.txt").is_err()); + assert_eq!(std::fs::read(outside.path()).unwrap(), b"outside"); +} + +#[test] +fn cleanup_removes_only_vnidrop_temporary_files() { + let output = tempfile::tempdir().unwrap(); + std::fs::write(output.path().join(".file.vnidrop-old.part"), b"partial").unwrap(); + std::fs::write(output.path().join("keep.part"), b"keep").unwrap(); + + assert_eq!( + cleanup_stale_temporary_files(output.path(), std::time::Duration::ZERO).unwrap(), + 1 + ); + assert!(!output.path().join(".file.vnidrop-old.part").exists()); + assert!(output.path().join("keep.part").exists()); +} + +#[test] +fn import_collection_limits_are_enforced_before_streaming() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("one.txt"), b"one").unwrap(); + std::fs::write(temp.path().join("two.txt"), b"two").unwrap(); + let source = ShareSource { + kind: SourceKind::Path, + value: temp.path().to_string_lossy().to_string(), + display_name: Some("folder".to_string()), + is_directory: true, + }; + + let file_limits = CoreLimits { + max_collection_files: 1, + ..CoreLimits::default() + }; + assert!(collect_import_files_with_limits(vec![source.clone()], &file_limits).is_err()); + + let size_limits = CoreLimits { + max_collection_files: 10, + max_total_bytes: 5, + ..CoreLimits::default() + }; + assert!(collect_import_files_with_limits(vec![source], &size_limits).is_err()); + + let path_limits = CoreLimits { + max_path_bytes: 4, + ..CoreLimits::default() + }; + let file = temp.path().join("long-name.txt"); + std::fs::write(&file, b"x").unwrap(); + assert!(collect_import_files_with_limits( + vec![ShareSource { + kind: SourceKind::Path, + value: file.to_string_lossy().to_string(), + display_name: Some("long-name.txt".to_string()), + is_directory: false, + }], + &path_limits, + ) + .is_err()); +} + +#[test] +fn generated_relative_paths_never_accept_traversal_components() { + for prefix in ["", "folder/", "a/b/"] { + for traversal in ["..", "../escape", "..\\escape"] { + let candidate = format!("{prefix}{traversal}"); + assert!( + validated_relative_string(&candidate).is_err(), + "{candidate}" + ); + } + } + assert!(validated_relative_string(".").is_err()); + assert!(validated_relative_string("/absolute").is_err()); +} diff --git a/crates/vnidrop/src/tests/handshake.rs b/crates/vnidrop/src/tests/handshake.rs new file mode 100644 index 0000000..7be2cbb --- /dev/null +++ b/crates/vnidrop/src/tests/handshake.rs @@ -0,0 +1,13 @@ +use crate::handshake::HandshakeResponse; + +#[test] +fn malformed_handshake_response_is_rejected() { + for payload in [ + r#"{"Approved":{"token":7,"expires_at":"later"}}"#, + r#"{"Denied":{}}"#, + r#"{"Unknown":{"reason":"no"}}"#, + "not-json", + ] { + assert!(serde_json::from_str::(payload).is_err()); + } +} diff --git a/crates/vnidrop/src/tests/limits.rs b/crates/vnidrop/src/tests/limits.rs new file mode 100644 index 0000000..0733c3c --- /dev/null +++ b/crates/vnidrop/src/tests/limits.rs @@ -0,0 +1,15 @@ +use crate::api::CoreLimits; + +#[test] +fn default_limits_are_valid() { + CoreLimits::default().validate().unwrap(); +} + +#[test] +fn zero_limit_is_rejected() { + let limits = CoreLimits { + max_sources: 0, + ..CoreLimits::default() + }; + assert!(limits.validate().is_err()); +} diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs new file mode 100644 index 0000000..6d6b052 --- /dev/null +++ b/crates/vnidrop/src/tests/repository.rs @@ -0,0 +1,516 @@ +use crate::{ + api::CoreEvent, + repository::{ReceiverRequestInsert, Repository, TransferUpsert}, + transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, +}; + +fn transfer( + transfer_id: u64, + direction: TransferDirection, + status: TransferStatus, +) -> TransferUpsert<'static> { + TransferUpsert { + transfer_id, + peer_id: None, + direction, + status, + transfer_name: Some("demo"), + content_hash: Some("hash"), + ticket: Some("ticket"), + file_count: 1, + total_size: 12, + access_mode: "approval_required", + } +} + +#[tokio::test] +async fn persists_transfers_and_events_across_reopen() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + assert_eq!(repository.schema_version().await.unwrap(), 3); + repository + .insert_transfer(transfer( + 7, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .unwrap(); + + let shares = repository.list_active_shares().await.unwrap(); + assert_eq!(shares.len(), 1); + assert_eq!(shares[0].transfer_id, 7); + assert_eq!(shares[0].content_hash, "hash"); + assert_eq!(shares[0].access_mode, "approval_required"); + + repository + .insert_event( + &CoreEvent { + id: "event-1".to_string(), + timestamp: 10, + scope: "transfer".to_string(), + transfer_id: Some(7), + direction: Some("send".to_string()), + phase: "ticket".to_string(), + kind: "created".to_string(), + data_json: "{}".to_string(), + }, + 500, + ) + .await + .unwrap(); + + let transfers = repository.list_transfers().await.unwrap(); + assert_eq!(transfers.len(), 1); + assert_eq!(transfers[0].transfer_name.as_deref(), Some("demo")); + + let events = repository.list_events(Some(7), 500).await.unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, "created"); + + drop(repository); + let reopened = Repository::open(temp.path()).await.unwrap(); + let transfers = reopened.list_transfers().await.unwrap(); + assert_eq!(transfers.len(), 1); + let events = reopened.list_events(Some(7), 500).await.unwrap(); + assert_eq!(events[0].id, "event-1"); +} + +#[tokio::test] +async fn receiver_request_can_only_be_resolved_once() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "request-1", + transfer_id: 77, + remote_endpoint_id: "node-a", + transfer_name: "demo", + receiver_name: Some("receiver"), + receiver_device_name: Some("phone"), + app_version: "0.1.0", + }) + .await + .unwrap(); + repository + .update_receiver_request_status("request-1", ReceiverRequestStatus::Accepted, None) + .await + .unwrap(); + + assert!(repository + .update_receiver_request_status("request-1", ReceiverRequestStatus::Refused, Some("late"),) + .await + .is_err()); + assert!(repository + .update_receiver_request_status("missing", ReceiverRequestStatus::Accepted, None) + .await + .is_err()); + + let requests = repository.list_receiver_requests(77).await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].status, "accepted"); + assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver")); + assert!(requests[0].responded_at.is_some()); +} + +#[tokio::test] +async fn startup_expiration_is_idempotent_for_pending_requests() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "pending-1", + transfer_id: 78, + remote_endpoint_id: "node-a", + transfer_name: "demo", + receiver_name: None, + receiver_device_name: None, + app_version: "0.1.0", + }) + .await + .unwrap(); + + assert_eq!( + repository + .expire_pending_receiver_requests("restart") + .await + .unwrap(), + 1 + ); + assert_eq!( + repository + .expire_pending_receiver_requests("restart") + .await + .unwrap(), + 0 + ); + let requests = repository.list_receiver_requests(78).await.unwrap(); + assert_eq!(requests[0].status, "expired"); + assert_eq!(requests[0].reason.as_deref(), Some("restart")); +} + +#[tokio::test] +async fn concurrent_approval_responses_have_single_winner() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "race-1", + transfer_id: 79, + remote_endpoint_id: "node-a", + transfer_name: "demo", + receiver_name: None, + receiver_device_name: None, + app_version: "0.1.0", + }) + .await + .unwrap(); + + let accepted_repository = repository.clone(); + let refused_repository = repository.clone(); + let (accepted, refused) = tokio::join!( + accepted_repository.update_receiver_request_status( + "race-1", + ReceiverRequestStatus::Accepted, + None, + ), + refused_repository.update_receiver_request_status( + "race-1", + ReceiverRequestStatus::Refused, + Some("race"), + ), + ); + assert_ne!(accepted.is_ok(), refused.is_ok()); + let requests = repository.list_receiver_requests(79).await.unwrap(); + assert!(matches!( + requests[0].status.as_str(), + "accepted" | "refused" + )); +} + +#[tokio::test] +async fn conditional_transition_rejects_stale_state() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 81, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .unwrap(); + + let error = repository + .transition_transfer_status(81, TransferStatus::Receiving, TransferStatus::Done) + .await + .unwrap_err(); + assert!(error.to_string().contains("expected one matching transfer")); + assert_eq!( + repository.list_transfers().await.unwrap()[0].status, + "sharing" + ); +} + +#[tokio::test] +async fn repeated_terminal_transition_is_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 88, + TransferDirection::Send, + TransferStatus::Importing, + )) + .await + .unwrap(); + + repository + .transition_transfer_status(88, TransferStatus::Importing, TransferStatus::Failed) + .await + .unwrap(); + repository + .transition_transfer_status(88, TransferStatus::Importing, TransferStatus::Failed) + .await + .unwrap(); + assert_eq!( + repository.list_transfers().await.unwrap()[0].status, + "failed" + ); +} + +#[tokio::test] +async fn duplicate_transfer_does_not_overwrite_existing_record() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 82, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .unwrap(); + + assert!(repository + .insert_transfer(transfer( + 82, + TransferDirection::Receive, + TransferStatus::Receiving, + )) + .await + .is_err()); + let stored = repository.list_transfers().await.unwrap().remove(0); + assert_eq!(stored.direction, "send"); + assert_eq!(stored.status, "sharing"); +} + +#[tokio::test] +async fn recovery_fails_only_interrupted_states() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(transfer( + 83, + TransferDirection::Send, + TransferStatus::Importing, + )) + .await + .unwrap(); + repository + .insert_transfer(transfer( + 84, + TransferDirection::Receive, + TransferStatus::Receiving, + )) + .await + .unwrap(); + repository + .insert_transfer(transfer( + 85, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .unwrap(); + + let recovered = repository.recover_interrupted_transfers().await.unwrap(); + assert_eq!(recovered.len(), 2); + assert_eq!(recovered[0].transfer_id, 83); + assert_eq!(recovered[0].previous_status, TransferStatus::Importing); + assert_eq!(recovered[1].transfer_id, 84); + assert_eq!(recovered[1].previous_status, TransferStatus::Receiving); + + let transfers = repository.list_transfers().await.unwrap(); + assert_eq!( + transfers + .iter() + .find(|transfer| transfer.transfer_id == 83) + .unwrap() + .status, + "failed" + ); + assert_eq!( + transfers + .iter() + .find(|transfer| transfer.transfer_id == 84) + .unwrap() + .status, + "failed" + ); + assert_eq!( + transfers + .iter() + .find(|transfer| transfer.transfer_id == 85) + .unwrap() + .status, + "sharing" + ); + assert!(repository + .recover_interrupted_transfers() + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn share_completion_is_conditional_and_atomic() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(TransferUpsert { + transfer_id: 86, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Importing, + transfer_name: Some("pending"), + content_hash: None, + ticket: None, + file_count: 0, + total_size: 0, + access_mode: "approval_required", + }) + .await + .unwrap(); + + repository + .complete_share_import(TransferUpsert { + transfer_id: 86, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Sharing, + transfer_name: Some("complete"), + content_hash: Some("final-hash"), + ticket: Some("final-ticket"), + file_count: 2, + total_size: 24, + access_mode: "approval_required", + }) + .await + .unwrap(); + + let stored = repository.list_transfers().await.unwrap().remove(0); + assert_eq!(stored.status, "sharing"); + assert_eq!(stored.transfer_name.as_deref(), Some("complete")); + assert_eq!(stored.content_hash.as_deref(), Some("final-hash")); + assert_eq!(stored.ticket.as_deref(), Some("final-ticket")); + assert_eq!(stored.file_count, 2); + assert_eq!(stored.total_size, 24); + + assert!(repository + .complete_share_import(transfer( + 86, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .is_err()); +} + +#[tokio::test] +async fn injected_write_failure_preserves_previous_state() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(TransferUpsert { + transfer_id: 87, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Importing, + transfer_name: Some("pending"), + content_hash: None, + ticket: None, + file_count: 0, + total_size: 0, + access_mode: "approval_required", + }) + .await + .unwrap(); + repository.fail_next_write(); + + assert!(repository + .complete_share_import(transfer( + 87, + TransferDirection::Send, + TransferStatus::Sharing, + )) + .await + .is_err()); + let stored = repository.list_transfers().await.unwrap().remove(0); + assert_eq!(stored.status, "importing"); + assert_eq!(stored.content_hash, None); + assert_eq!(stored.ticket, None); +} + +#[tokio::test] +async fn migrates_schema_v2_identity_without_losing_transfer() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("vnidrop.sqlite3"); + let options = SqliteConnectOptions::from_str("sqlite://") + .unwrap() + .filename(&database) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::query( + r#" + CREATE TABLE transfers ( + transfer_id INTEGER PRIMARY KEY, + direction TEXT NOT NULL, + status TEXT NOT NULL, + transfer_name TEXT, + content_hash TEXT, + ticket TEXT, + file_count INTEGER NOT NULL DEFAULT 0, + total_size INTEGER NOT NULL DEFAULT 0, + access_mode TEXT NOT NULL DEFAULT 'approval_required', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + r#" + INSERT INTO transfers ( + transfer_id, direction, status, transfer_name, content_hash, ticket, + file_count, total_size, access_mode, created_at, updated_at + ) VALUES (7, 'send', 'stopped', 'legacy', 'hash', 'ticket', 1, 12, + 'approval_required', 10, 11) + "#, + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query("PRAGMA user_version = 2") + .execute(&pool) + .await + .unwrap(); + pool.close().await; + + let repository = Repository::open(temp.path()).await.unwrap(); + assert_eq!(repository.schema_version().await.unwrap(), 3); + let stored = repository.list_transfers().await.unwrap().remove(0); + assert_eq!(stored.transfer_id, 7); + assert_eq!(stored.local_id, "legacy-7-send"); + assert_eq!(stored.transfer_name.as_deref(), Some("legacy")); + assert_eq!(stored.ticket.as_deref(), Some("ticket")); + assert_eq!(stored.peer_id, None); +} + +#[tokio::test] +async fn event_reads_respect_configured_history_limit() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + for sequence in 0..3 { + repository + .insert_event( + &CoreEvent { + id: format!("event-{sequence}"), + timestamp: sequence, + scope: "endpoint".to_string(), + transfer_id: None, + direction: None, + phase: "test".to_string(), + kind: "generated".to_string(), + data_json: "{}".to_string(), + }, + 2, + ) + .await + .unwrap(); + } + + let events = repository.list_events(None, 2).await.unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].id, "event-2"); + assert_eq!(events[1].id, "event-1"); +} +use std::str::FromStr; + +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; diff --git a/crates/vnidrop/src/tests/runtime.rs b/crates/vnidrop/src/tests/runtime.rs new file mode 100644 index 0000000..6591a97 --- /dev/null +++ b/crates/vnidrop/src/tests/runtime.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use iroh_blobs::Hash; + +use crate::{ + repository::{Repository, TransferUpsert}, + transfer_state::{TransferDirection, TransferStatus}, + CoreEvent, CoreEventSink, VnidropCore, VnidropError, +}; + +struct TestSink; + +impl CoreEventSink for TestSink { + fn on_event(&self, _event: CoreEvent) {} +} + +#[test] +fn initializes_and_reports_endpoint() { + let temp = tempfile::tempdir().unwrap(); + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + + assert!(!core.status().endpoint_id.is_empty()); + core.shutdown(); +} + +#[test] +fn invalid_receive_ticket_is_typed_and_persisted_as_event() { + let temp = tempfile::tempdir().unwrap(); + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + + let error = core + .receive( + "not-a-ticket".to_string(), + temp.path().to_string_lossy().to_string(), + None, + ) + .unwrap_err(); + assert!(matches!(error, VnidropError::Ticket { .. })); + + let events = core.list_events(None).unwrap(); + assert!(events + .iter() + .any(|event| event.phase == "error" && event.kind == "invalid-ticket")); + core.shutdown(); +} + +#[test] +fn startup_recovers_interrupted_transfer_and_persists_event() { + let temp = tempfile::tempdir().unwrap(); + let preparation_runtime = tokio::runtime::Runtime::new().unwrap(); + preparation_runtime.block_on(async { + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_transfer(TransferUpsert { + transfer_id: 91, + peer_id: None, + direction: TransferDirection::Receive, + status: TransferStatus::Receiving, + transfer_name: Some("interrupted"), + content_hash: Some("hash"), + ticket: None, + file_count: 1, + total_size: 5, + access_mode: "approval_required", + }) + .await + .unwrap(); + }); + drop(preparation_runtime); + + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + let transfer = core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 91) + .unwrap(); + assert_eq!(transfer.status, "failed"); + + let events = core.list_events(Some(91)).unwrap(); + assert!(events.iter().any(|event| { + event.phase == "recovery" + && event.kind == "interrupted-transfer-failed" + && event.data_json.contains("receiving") + })); + core.shutdown(); +} + +#[test] +fn startup_fails_persisted_share_when_root_blob_is_missing() { + let temp = tempfile::tempdir().unwrap(); + let preparation_runtime = tokio::runtime::Runtime::new().unwrap(); + preparation_runtime.block_on(async { + let repository = Repository::open(temp.path()).await.unwrap(); + let missing_hash = Hash::new([42; 32]).to_string(); + repository + .insert_transfer(TransferUpsert { + transfer_id: 92, + peer_id: None, + direction: TransferDirection::Send, + status: TransferStatus::Sharing, + transfer_name: Some("missing blob"), + content_hash: Some(&missing_hash), + ticket: Some("ticket"), + file_count: 1, + total_size: 5, + access_mode: "approval_required", + }) + .await + .unwrap(); + }); + drop(preparation_runtime); + + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + let transfer = core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 92) + .unwrap(); + assert_eq!(transfer.status, "failed"); + assert_eq!(core.status().active_shares, 0); + assert!(core.list_events(Some(92)).unwrap().iter().any(|event| { + event.phase == "recovery" && event.kind == "share-root-missing-or-corrupt" + })); + core.shutdown(); +} diff --git a/crates/vnidrop/src/tests/secret.rs b/crates/vnidrop/src/tests/secret.rs new file mode 100644 index 0000000..20b6bcd --- /dev/null +++ b/crates/vnidrop/src/tests/secret.rs @@ -0,0 +1,22 @@ +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use crate::secret::load_or_create_secret; + +#[tokio::test] +async fn persists_with_restricted_permissions() { + let temp = tempfile::tempdir().unwrap(); + let first = load_or_create_secret(temp.path()).await.unwrap(); + let second = load_or_create_secret(temp.path()).await.unwrap(); + assert_eq!(first.to_bytes(), second.to_bytes()); + + #[cfg(unix)] + assert_eq!( + std::fs::metadata(temp.path().join("iroh.secret")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); +} diff --git a/crates/vnidrop/src/tests/ticket.rs b/crates/vnidrop/src/tests/ticket.rs new file mode 100644 index 0000000..62ace59 --- /dev/null +++ b/crates/vnidrop/src/tests/ticket.rs @@ -0,0 +1,134 @@ +use data_encoding::BASE64URL_NOPAD; +use iroh::SecretKey; +use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; +use serde_json::json; + +use crate::{ + api::{CoreLimits, TransferMetadata}, + ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket}, +}; + +fn blob_ticket(hash_byte: u8) -> BlobTicket { + let secret = SecretKey::generate(); + let addr = iroh::EndpointAddr::new(secret.public()); + BlobTicket::new(addr, Hash::new([hash_byte; 32]), BlobFormat::HashSeq) +} + +#[test] +fn metadata_ticket_round_trips() { + let blob_ticket = blob_ticket(7); + let metadata = TransferMetadata::new( + 42, + "Summer photos", + Some("hammed".to_string()), + blob_ticket.hash(), + 3, + 2048, + ); + let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone()) + .encode() + .unwrap(); + let parsed = parse_transfer_ticket(&encoded).unwrap(); + + assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); + assert_eq!( + parsed.metadata.unwrap().transfer_name, + metadata.transfer_name + ); +} + +#[test] +fn metadata_ticket_tolerates_wrapped_whitespace() { + let blob_ticket = blob_ticket(9); + let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10); + let encoded = VnidropTicket::new(blob_ticket.clone(), metadata) + .encode() + .unwrap(); + let wrapped = encoded + .as_bytes() + .chunks(8) + .map(|chunk| std::str::from_utf8(chunk).unwrap()) + .collect::>() + .join("\n "); + + let parsed = parse_transfer_ticket(&wrapped).unwrap(); + assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); +} + +#[test] +fn invalid_ticket_is_rejected() { + assert!(parse_transfer_ticket("not-a-ticket").is_err()); +} + +#[test] +fn rejects_unsupported_versions_and_mismatched_hashes() { + let blob_ticket = blob_ticket(5); + let payload = json!({ + "version": 2, + "blob_ticket": blob_ticket.to_string(), + "metadata": { + "version": 1, + "transfer_id": 7, + "transfer_name": "bad version", + "sender_name": null, + "created_at": 1, + "content_hash": blob_ticket.hash().to_string(), + "file_count": 1, + "total_size": 10 + } + }); + let encoded = format!( + "vnd1:{}", + BASE64URL_NOPAD.encode(payload.to_string().as_bytes()) + ); + assert!(parse_transfer_ticket(&encoded) + .unwrap_err() + .to_string() + .contains("unsupported VniDrop ticket version")); + + let payload = json!({ + "version": 1, + "blob_ticket": blob_ticket.to_string(), + "metadata": { + "version": 1, + "transfer_id": 7, + "transfer_name": "bad hash", + "sender_name": null, + "created_at": 1, + "content_hash": Hash::new([6; 32]).to_string(), + "file_count": 1, + "total_size": 10 + } + }); + let encoded = format!( + "vnd1:{}", + BASE64URL_NOPAD.encode(payload.to_string().as_bytes()) + ); + assert!(parse_transfer_ticket(&encoded) + .unwrap_err() + .to_string() + .contains("metadata hash does not match")); +} + +#[test] +fn rejects_ticket_over_configured_size_limit() { + let limits = CoreLimits { + max_ticket_bytes: 8, + ..CoreLimits::default() + }; + assert!(parse_transfer_ticket_with_limits("not-a-ticket", &limits).is_err()); +} + +#[test] +fn parser_rejects_or_parses_generated_inputs_without_panicking() { + let alphabet = b"vnd1:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ /\\\n"; + let mut state = 0x9e37_79b9u32; + for len in 0..512usize { + let mut input = String::with_capacity(len); + for _ in 0..len { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + input.push(alphabet[state as usize % alphabet.len()] as char); + } + let _ = parse_transfer_ticket(&input); + } +} diff --git a/crates/vnidrop/src/tests/transfer_state.rs b/crates/vnidrop/src/tests/transfer_state.rs new file mode 100644 index 0000000..68360a9 --- /dev/null +++ b/crates/vnidrop/src/tests/transfer_state.rs @@ -0,0 +1,31 @@ +use crate::transfer_state::{TransferDirection, TransferStatus}; + +#[test] +fn parses_only_known_persisted_values() { + assert_eq!( + TransferDirection::try_from("send").unwrap(), + TransferDirection::Send + ); + assert_eq!( + TransferStatus::try_from("receiving").unwrap(), + TransferStatus::Receiving + ); + assert!(TransferDirection::try_from("sideways").is_err()); + assert!(TransferStatus::try_from("pending-ish").is_err()); +} + +#[test] +fn permits_only_defined_lifecycle_transitions() { + assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Sharing)); + assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Failed)); + assert!(TransferStatus::Importing.can_transition_to(TransferStatus::Cancelled)); + assert!(TransferStatus::Sharing.can_transition_to(TransferStatus::Stopped)); + assert!(TransferStatus::Sharing.can_transition_to(TransferStatus::Failed)); + assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Done)); + assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Failed)); + assert!(TransferStatus::Receiving.can_transition_to(TransferStatus::Cancelled)); + + assert!(!TransferStatus::Sharing.can_transition_to(TransferStatus::Done)); + assert!(!TransferStatus::Done.can_transition_to(TransferStatus::Receiving)); + assert!(!TransferStatus::Failed.can_transition_to(TransferStatus::Sharing)); +} diff --git a/crates/vnidrop/tests/README.md b/crates/vnidrop/tests/README.md new file mode 100644 index 0000000..a0eed30 --- /dev/null +++ b/crates/vnidrop/tests/README.md @@ -0,0 +1,44 @@ +# Core test organization + +VniDrop uses two complementary Rust test layers. + +## Internal tests + +Tests under `src/tests/` can exercise crate-private invariants without widening +the production API: + +- `access_policy.rs`: authorization and approval-session rules. +- `filesystem.rs`: source collection and path validation. +- `handshake.rs`: malformed protocol response handling. +- `limits.rs`: core-limit validation. +- `repository.rs`: schema, persistence, and transition invariants. +- `runtime.rs`: public error mapping and runtime orchestration. +- `secret.rs`: node identity persistence and file permissions. +- `ticket.rs`: ticket encoding, parsing, and metadata validation. +- `transfer_state.rs`: persisted enum parsing and legal lifecycle transitions. + +## Integration tests + +Files directly under `tests/` are black-box scenarios. They must use the public +`vnidrop` API and should be organized by behavior rather than implementation +module: + +- `approval.rs`: receiver authorization flows. +- `lifecycle.rs`: stop, restart, recovery, and revocation behavior. +- `output_sink.rs`: foreign output-sink contracts and failures. +- `transfer.rs`: end-to-end file and directory transfers. + +Reusable fixtures live in `tests/support/`. `CoreGuard` shuts down a test core +on drop, while `RecordingSink` and `MemoryOutputSink` keep assertions focused +on externally observable behavior. + +## Test requirements + +- Every bug fix must include a regression test. +- Avoid arbitrary sleeps. When polling an asynchronous boundary is unavoidable, + use a short interval and a bounded timeout with a useful failure message. +- Prefer deterministic IDs, inputs, and clocks. +- Do not expose production internals solely for integration tests. +- Failure tests must verify durable status and emitted events when applicable, + not only the returned error. +- Recovery tests must close the original core and reopen the same data directory. diff --git a/crates/vnidrop/tests/approval.rs b/crates/vnidrop/tests/approval.rs new file mode 100644 index 0000000..6c011b3 --- /dev/null +++ b/crates/vnidrop/tests/approval.rs @@ -0,0 +1,130 @@ +mod support; + +use std::sync::Arc; + +use support::{ + receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink, + TestNode, +}; +use vnidrop::CoreLimits; + +#[test] +fn approval_required_denies_then_allows_receiver() { + let source_dir = tempfile::tempdir().unwrap(); + let denied_output = tempfile::tempdir().unwrap(); + let allowed_output = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("private.txt"); + std::fs::write(&source_path, b"approved content").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 9, "private.txt", false); + + assert!(receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket.clone(), + denied_output.path(), + false, + ) + .is_err()); + assert!(sender + .sink + .events() + .iter() + .any(|event| event.phase == "approval" && event.kind == "receiver-refused")); + + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + allowed_output.path(), + true, + ) + .unwrap(); + assert_eq!( + std::fs::read(allowed_output.path().join("private.txt")).unwrap(), + b"approved content" + ); +} + +#[test] +fn receiver_can_cancel_while_waiting_for_approval() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("waiting.txt"); + std::fs::write(&source_path, b"waiting").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 26, "waiting.txt", false); + let receiver_core = receiver.core.arc(); + let ticket = share.ticket; + let output = output_dir.path().to_string_lossy().to_string(); + let worker = std::thread::spawn(move || { + receiver_core.receive(ticket, output, Some("receiver".to_string())) + }); + + let request = wait_for_receiver_request(&sender.core, share.transfer_id); + receiver.core.cancel_transfer(share.transfer_id).unwrap(); + let _ = sender.core.respond_receiver_request( + request.id, + false, + Some("receiver-cancelled".to_string()), + ); + assert!(worker.join().unwrap().is_err()); + + let transfer = receiver + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == share.transfer_id) + .unwrap(); + assert_eq!(transfer.status, "cancelled"); +} + +#[test] +fn pending_approval_limit_denies_excess_receiver() { + let sender_dir = tempfile::tempdir().unwrap(); + let source_dir = tempfile::tempdir().unwrap(); + let output_one = tempfile::tempdir().unwrap(); + let output_two = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("limited.txt"); + std::fs::write(&source_path, b"limited").unwrap(); + let limits = CoreLimits { + max_pending_approvals: 1, + ..CoreLimits::default() + }; + let sender = CoreGuard::start_with_limits( + sender_dir.path(), + Arc::new(RecordingSink::default()), + limits, + ); + let receiver_one = TestNode::new(); + let receiver_two = TestNode::new(); + let share = share_path(&sender, &source_path, 28, "limited.txt", false); + + let first_core = receiver_one.core.arc(); + let first_ticket = share.ticket.clone(); + let first_output = output_one.path().to_string_lossy().to_string(); + let first = std::thread::spawn(move || { + first_core.receive(first_ticket, first_output, Some("first".to_string())) + }); + let request = wait_for_receiver_request(&sender, share.transfer_id); + + let second = receiver_two.core.receive( + share.ticket, + output_two.path().to_string_lossy().to_string(), + Some("second".to_string()), + ); + assert!(second + .unwrap_err() + .to_string() + .contains("too-many-pending-approvals")); + + sender + .respond_receiver_request(request.id, false, Some("test complete".to_string())) + .unwrap(); + assert!(first.join().unwrap().is_err()); +} diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs new file mode 100644 index 0000000..fc599f0 --- /dev/null +++ b/crates/vnidrop/tests/lifecycle.rs @@ -0,0 +1,251 @@ +mod support; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use support::{share_path, CoreGuard, RecordingSink, TestNode}; +use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode}; + +#[test] +fn cancelling_share_updates_status_and_events() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("cancel.txt"); + std::fs::write(&source_path, b"cancel me").unwrap(); + let sender = TestNode::new(); + let share = share_path(&sender.core, &source_path, 10, "cancel.txt", false); + + sender.core.cancel_transfer(share.transfer_id).unwrap(); + + let transfers = sender.core.list_transfers().unwrap(); + assert_eq!(transfers[0].status, "stopped"); + assert!(sender + .sink + .events() + .iter() + .any(|event| event.kind == "share-stopped")); +} + +#[test] +fn persisted_share_is_recovered_and_can_be_stopped_after_restart() { + let source_dir = tempfile::tempdir().unwrap(); + let core_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("persistent.txt"); + std::fs::write(&source_path, b"survives restart").unwrap(); + + let sender = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default())); + let share = share_path(&sender, &source_path, 20, "persistent.txt", false); + drop(sender); + + let restarted = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default())); + assert_eq!(restarted.status().active_shares, 1); + restarted.cancel_transfer(share.transfer_id).unwrap(); + + let transfer = restarted + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == share.transfer_id) + .unwrap(); + assert_eq!(transfer.status, "stopped"); + assert_eq!(restarted.status().active_shares, 0); +} + +#[test] +fn stopped_share_rejects_direct_legacy_blob_ticket() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("revoked.txt"); + std::fs::write(&source_path, b"must not be served").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 21, "revoked.txt", false); + + sender.core.cancel_transfer(share.transfer_id).unwrap(); + let result = receiver.core.receive( + share.blob_ticket, + output_dir.path().to_string_lossy().to_string(), + Some("receiver".to_string()), + ); + + assert!(result.is_err(), "a stopped share must not serve blob bytes"); + assert!(!output_dir.path().join("revoked.txt").exists()); +} + +#[test] +fn failed_import_leaves_durable_failed_transfer() { + let source_dir = tempfile::tempdir().unwrap(); + let sender = TestNode::new(); + let transfer_id = 22; + + let result = sender.core.share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_dir + .path() + .join("missing.txt") + .to_string_lossy() + .to_string(), + display_name: Some("missing.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id, + transfer_name: Some("missing".to_string()), + sender_name: None, + }, + ); + + assert!(result.is_err()); + let transfer = sender + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == transfer_id) + .unwrap(); + assert_eq!(transfer.status, "failed"); + assert_eq!(sender.core.status().active_shares, 0); +} + +#[test] +fn duplicate_transfer_id_does_not_replace_active_share() { + let source_dir = tempfile::tempdir().unwrap(); + let first_path = source_dir.path().join("first.txt"); + let second_path = source_dir.path().join("second.txt"); + std::fs::write(&first_path, b"first").unwrap(); + std::fs::write(&second_path, b"second").unwrap(); + let sender = TestNode::new(); + let first = share_path(&sender.core, &first_path, 23, "first.txt", false); + + let duplicate = sender.core.share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: second_path.to_string_lossy().to_string(), + display_name: Some("second.txt".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: first.transfer_id, + transfer_name: Some("second".to_string()), + sender_name: None, + }, + ); + + assert!(duplicate.is_err()); + assert_eq!(sender.core.status().active_shares, 1); + let transfer = sender + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == first.transfer_id) + .unwrap(); + assert_eq!(transfer.status, "sharing"); + assert_eq!(transfer.transfer_name.as_deref(), Some("first.txt")); +} + +#[test] +fn access_mode_update_requires_active_persisted_share() { + let sender = TestNode::new(); + + assert!(sender + .core + .set_transfer_access_mode(999, TransferAccessMode::Public) + .is_err()); +} + +#[test] +fn source_limit_rejection_creates_no_transfer_state() { + let core_dir = tempfile::tempdir().unwrap(); + let source_dir = tempfile::tempdir().unwrap(); + let first = source_dir.path().join("one.txt"); + let second = source_dir.path().join("two.txt"); + std::fs::write(&first, b"one").unwrap(); + std::fs::write(&second, b"two").unwrap(); + let limits = CoreLimits { + max_sources: 1, + ..CoreLimits::default() + }; + let sender = + CoreGuard::start_with_limits(core_dir.path(), Arc::new(RecordingSink::default()), limits); + + let result = sender.share_files( + vec![ + ShareSource { + kind: SourceKind::Path, + value: first.to_string_lossy().to_string(), + display_name: Some("one.txt".to_string()), + is_directory: false, + }, + ShareSource { + kind: SourceKind::Path, + value: second.to_string_lossy().to_string(), + display_name: Some("two.txt".to_string()), + is_directory: false, + }, + ], + ShareMetadataInput { + transfer_id: 24, + transfer_name: Some("too many".to_string()), + sender_name: None, + }, + ); + + assert!(result.is_err()); + assert!(sender.list_transfers().unwrap().is_empty()); +} + +#[test] +fn cancellation_during_import_is_durable() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("large.bin"); + std::fs::File::create(&source_path) + .unwrap() + .set_len(256 * 1024 * 1024) + .unwrap(); + let sender = TestNode::new(); + let core = sender.core.arc(); + let worker = std::thread::spawn(move || { + core.share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source_path.to_string_lossy().to_string(), + display_name: Some("large.bin".to_string()), + is_directory: false, + }], + ShareMetadataInput { + transfer_id: 25, + transfer_name: Some("large".to_string()), + sender_name: None, + }, + ) + }); + + let started = Instant::now(); + loop { + if sender + .core + .list_transfers() + .unwrap() + .iter() + .any(|transfer| transfer.transfer_id == 25 && transfer.status == "importing") + { + break; + } + assert!(started.elapsed() < Duration::from_secs(10)); + std::thread::sleep(Duration::from_millis(10)); + } + sender.core.cancel_transfer(25).unwrap(); + assert!(worker.join().unwrap().is_err()); + + let transfer = sender + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 25) + .unwrap(); + assert_eq!(transfer.status, "cancelled"); + assert_eq!(sender.core.status().active_transfers, 0); + assert_eq!(sender.core.status().active_shares, 0); +} diff --git a/crates/vnidrop/tests/local_transfer.rs b/crates/vnidrop/tests/local_transfer.rs deleted file mode 100644 index 914ed55..0000000 --- a/crates/vnidrop/tests/local_transfer.rs +++ /dev/null @@ -1,496 +0,0 @@ -use std::{ - collections::HashMap, - sync::{Arc, Mutex}, - time::{Duration, Instant}, -}; - -use vnidrop::{ - CoreEvent, CoreEventSink, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, ShareSource, - SourceKind, VnidropCore, VnidropError, -}; - -#[derive(Default)] -struct RecordingSink { - events: Mutex>, -} - -impl CoreEventSink for RecordingSink { - fn on_event(&self, event: CoreEvent) { - self.events.lock().unwrap().push(event); - } -} - -impl RecordingSink { - fn events(&self) -> Vec { - self.events.lock().unwrap().clone() - } -} - -#[derive(Default)] -struct MemoryOutputSink { - files: Mutex>>, - fail_writes: bool, -} - -impl ReceiveOutputSink for MemoryOutputSink { - fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { - self.files.lock().unwrap().insert(relative_path, Vec::new()); - Ok(()) - } - - fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { - if self.fail_writes { - return Err(VnidropError::Filesystem { - reason: "sink write failed".to_string(), - }); - } - self.files - .lock() - .unwrap() - .get_mut(&relative_path) - .expect("file was not started") - .extend(bytes); - Ok(()) - } - - fn finish_file(&self, _relative_path: String) -> Result<(), VnidropError> { - Ok(()) - } -} - -impl MemoryOutputSink { - fn file(&self, relative_path: &str) -> Vec { - self.files.lock().unwrap()[relative_path].clone() - } -} - -fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest { - let started = Instant::now(); - loop { - let requests = sender.list_receiver_requests(transfer_id).unwrap(); - if let Some(request) = requests - .into_iter() - .find(|request| request.status == "requested") - { - return request; - } - assert!( - started.elapsed() < Duration::from_secs(15), - "timed out waiting for receiver request" - ); - std::thread::sleep(Duration::from_millis(50)); - } -} - -fn receive_with_response( - sender: &VnidropCore, - transfer_id: u64, - receiver: Arc, - ticket: String, - output_dir: String, - receiver_name: Option, - accepted: bool, -) -> Result<(), String> { - let handle = std::thread::spawn(move || { - receiver - .receive(ticket, output_dir, receiver_name) - .map_err(|error| error.to_string()) - }); - let request = wait_for_receiver_request(sender, transfer_id); - sender - .respond_receiver_request( - request.id, - accepted, - (!accepted).then(|| "sender-refused".to_string()), - ) - .unwrap(); - handle.join().unwrap() -} - -fn receive_with_sink_response( - sender: &VnidropCore, - transfer_id: u64, - receiver: Arc, - ticket: String, - output_sink: Arc, - receiver_name: Option, - accepted: bool, -) -> Result<(), String> { - let handle = std::thread::spawn(move || { - receiver - .receive_with_output_sink(ticket, output_sink, receiver_name) - .map_err(|error| error.to_string()) - }); - let request = wait_for_receiver_request(sender, transfer_id); - sender - .respond_receiver_request( - request.id, - accepted, - (!accepted).then(|| "sender-refused".to_string()), - ) - .unwrap(); - handle.join().unwrap() -} - -#[test] -fn two_local_cores_transfer_file() { - let sender_dir = tempfile::tempdir().unwrap(); - let receiver_dir = tempfile::tempdir().unwrap(); - let output_dir = tempfile::tempdir().unwrap(); - let source_path = sender_dir.path().join("hello.txt"); - std::fs::write(&source_path, b"hello from vnidrop").unwrap(); - - let sender_sink = Arc::new(RecordingSink::default()); - let receiver_sink = Arc::new(RecordingSink::default()); - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - sender_sink.clone(), - ) - .unwrap(); - let receiver = VnidropCore::initialize( - receiver_dir - .path() - .join("core") - .to_string_lossy() - .to_string(), - receiver_sink.clone(), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_path.to_string_lossy().to_string(), - display_name: Some("hello.txt".to_string()), - is_directory: false, - }], - ShareMetadataInput { - transfer_id: 7, - transfer_name: Some("hello".to_string()), - sender_name: Some("sender".to_string()), - }, - ) - .unwrap(); - - receive_with_response( - &sender, - share.transfer_id, - receiver.clone(), - share.ticket, - output_dir.path().to_string_lossy().to_string(), - Some("receiver".to_string()), - true, - ) - .unwrap(); - - assert_eq!( - std::fs::read(output_dir.path().join("hello.txt")).unwrap(), - b"hello from vnidrop" - ); - - sender.shutdown(); - receiver.shutdown(); -} - -#[test] -fn two_local_cores_transfer_directory() { - let sender_dir = tempfile::tempdir().unwrap(); - let receiver_dir = tempfile::tempdir().unwrap(); - let output_dir = tempfile::tempdir().unwrap(); - let source_root = sender_dir.path().join("photos"); - std::fs::create_dir_all(source_root.join("nested")).unwrap(); - std::fs::write(source_root.join("cover.txt"), b"cover").unwrap(); - std::fs::write(source_root.join("nested").join("inside.txt"), b"inside").unwrap(); - - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - let receiver = VnidropCore::initialize( - receiver_dir - .path() - .join("core") - .to_string_lossy() - .to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_root.to_string_lossy().to_string(), - display_name: Some("photos".to_string()), - is_directory: true, - }], - ShareMetadataInput { - transfer_id: 8, - transfer_name: Some("photos".to_string()), - sender_name: Some("sender".to_string()), - }, - ) - .unwrap(); - - receive_with_response( - &sender, - share.transfer_id, - receiver.clone(), - share.ticket, - output_dir.path().to_string_lossy().to_string(), - Some("receiver".to_string()), - true, - ) - .unwrap(); - - assert_eq!( - std::fs::read(output_dir.path().join("photos").join("cover.txt")).unwrap(), - b"cover" - ); - assert_eq!( - std::fs::read( - output_dir - .path() - .join("photos") - .join("nested") - .join("inside.txt") - ) - .unwrap(), - b"inside" - ); - - sender.shutdown(); - receiver.shutdown(); -} - -#[test] -fn output_sink_receive_exports_nested_files() { - let sender_dir = tempfile::tempdir().unwrap(); - let receiver_dir = tempfile::tempdir().unwrap(); - let source_root = sender_dir.path().join("photos"); - std::fs::create_dir_all(source_root.join("nested")).unwrap(); - std::fs::write(source_root.join("cover.txt"), b"cover").unwrap(); - std::fs::write(source_root.join("nested").join("inside.txt"), b"inside").unwrap(); - - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - let receiver = VnidropCore::initialize( - receiver_dir - .path() - .join("core") - .to_string_lossy() - .to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_root.to_string_lossy().to_string(), - display_name: Some("photos".to_string()), - is_directory: true, - }], - ShareMetadataInput { - transfer_id: 18, - transfer_name: Some("photos".to_string()), - sender_name: Some("sender".to_string()), - }, - ) - .unwrap(); - let output_sink = Arc::new(MemoryOutputSink::default()); - - receive_with_sink_response( - &sender, - share.transfer_id, - receiver.clone(), - share.ticket, - output_sink.clone(), - Some("receiver".to_string()), - true, - ) - .unwrap(); - - assert_eq!(output_sink.file("photos/cover.txt"), b"cover"); - assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside"); - - sender.shutdown(); - receiver.shutdown(); -} - -#[test] -fn output_sink_receive_fails_when_sink_write_fails() { - let sender_dir = tempfile::tempdir().unwrap(); - let receiver_dir = tempfile::tempdir().unwrap(); - let source_path = sender_dir.path().join("hello.txt"); - std::fs::write(&source_path, b"hello").unwrap(); - - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - let receiver = VnidropCore::initialize( - receiver_dir - .path() - .join("core") - .to_string_lossy() - .to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_path.to_string_lossy().to_string(), - display_name: Some("hello.txt".to_string()), - is_directory: false, - }], - ShareMetadataInput { - transfer_id: 19, - transfer_name: Some("hello".to_string()), - sender_name: Some("sender".to_string()), - }, - ) - .unwrap(); - let output_sink = Arc::new(MemoryOutputSink { - files: Mutex::new(HashMap::new()), - fail_writes: true, - }); - - let error = receive_with_sink_response( - &sender, - share.transfer_id, - receiver.clone(), - share.ticket, - output_sink, - Some("receiver".to_string()), - true, - ) - .unwrap_err(); - - assert!(error.contains("sink write failed")); - - sender.shutdown(); - receiver.shutdown(); -} - -#[test] -fn approval_required_denies_then_allows_receiver() { - let sender_dir = tempfile::tempdir().unwrap(); - let receiver_dir = tempfile::tempdir().unwrap(); - let denied_output = tempfile::tempdir().unwrap(); - let allowed_output = tempfile::tempdir().unwrap(); - let source_path = sender_dir.path().join("private.txt"); - std::fs::write(&source_path, b"approved content").unwrap(); - - let sender_sink = Arc::new(RecordingSink::default()); - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - sender_sink.clone(), - ) - .unwrap(); - let receiver = VnidropCore::initialize( - receiver_dir - .path() - .join("core") - .to_string_lossy() - .to_string(), - Arc::new(RecordingSink::default()), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_path.to_string_lossy().to_string(), - display_name: Some("private.txt".to_string()), - is_directory: false, - }], - ShareMetadataInput { - transfer_id: 9, - transfer_name: Some("private".to_string()), - sender_name: None, - }, - ) - .unwrap(); - assert!(receive_with_response( - &sender, - share.transfer_id, - receiver.clone(), - share.ticket.clone(), - denied_output.path().to_string_lossy().to_string(), - Some("receiver".to_string()), - false, - ) - .is_err()); - assert!(sender_sink - .events() - .iter() - .any(|event| event.phase == "approval" && event.kind == "receiver-refused")); - - receive_with_response( - &sender, - share.transfer_id, - receiver.clone(), - share.ticket, - allowed_output.path().to_string_lossy().to_string(), - Some("receiver".to_string()), - true, - ) - .unwrap(); - assert_eq!( - std::fs::read(allowed_output.path().join("private.txt")).unwrap(), - b"approved content" - ); - - sender.shutdown(); - receiver.shutdown(); -} - -#[test] -fn cancelling_share_updates_status_and_events() { - let sender_dir = tempfile::tempdir().unwrap(); - let source_path = sender_dir.path().join("cancel.txt"); - std::fs::write(&source_path, b"cancel me").unwrap(); - let sink = Arc::new(RecordingSink::default()); - let sender = VnidropCore::initialize( - sender_dir.path().join("core").to_string_lossy().to_string(), - sink.clone(), - ) - .unwrap(); - - let share = sender - .share_files( - vec![ShareSource { - kind: SourceKind::Path, - value: source_path.to_string_lossy().to_string(), - display_name: Some("cancel.txt".to_string()), - is_directory: false, - }], - ShareMetadataInput { - transfer_id: 10, - transfer_name: Some("cancel".to_string()), - sender_name: None, - }, - ) - .unwrap(); - sender.cancel_transfer(share.transfer_id).unwrap(); - - let transfers = sender.list_transfers().unwrap(); - assert_eq!(transfers[0].status, "stopped"); - assert!(sink - .events() - .iter() - .any(|event| event.kind == "share-stopped")); - sender.shutdown(); -} diff --git a/crates/vnidrop/tests/output_sink.rs b/crates/vnidrop/tests/output_sink.rs new file mode 100644 index 0000000..e8512d8 --- /dev/null +++ b/crates/vnidrop/tests/output_sink.rs @@ -0,0 +1,104 @@ +mod support; + +use std::sync::Arc; + +use std::time::{Duration, Instant}; + +use support::{ + receive_with_sink_response, share_path, wait_for_receiver_request, MemoryOutputSink, TestNode, +}; + +#[test] +fn exports_nested_files_to_output_sink() { + let source_dir = tempfile::tempdir().unwrap(); + let source_root = source_dir.path().join("photos"); + std::fs::create_dir_all(source_root.join("nested")).unwrap(); + std::fs::write(source_root.join("cover.txt"), b"cover").unwrap(); + std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_root, 18, "photos", true); + let output_sink = Arc::new(MemoryOutputSink::default()); + + receive_with_sink_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_sink.clone(), + true, + ) + .unwrap(); + + assert_eq!(output_sink.file("photos/cover.txt"), b"cover"); + assert_eq!(output_sink.file("photos/nested/inside.txt"), b"inside"); + assert_eq!( + output_sink.terminal_state("photos/cover.txt"), + Some("finished") + ); + assert_eq!( + output_sink.terminal_state("photos/nested/inside.txt"), + Some("finished") + ); +} + +#[test] +fn reports_output_sink_write_failure() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"hello").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 19, "hello.txt", false); + let output_sink = Arc::new(MemoryOutputSink::failing_writes()); + + let error = receive_with_sink_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_sink.clone(), + true, + ) + .unwrap_err(); + + assert!(error.contains("sink write failed")); + assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted")); +} + +#[test] +fn cancellation_during_export_aborts_open_sink_file() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("slow.bin"); + std::fs::File::create(&source_path) + .unwrap() + .set_len(32 * 1024 * 1024) + .unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 29, "slow.bin", false); + let output_sink = Arc::new(MemoryOutputSink::slow_writes(Duration::from_millis(25))); + let receiver_core = receiver.core.arc(); + let sink_for_worker = output_sink.clone(); + let worker = std::thread::spawn(move || { + receiver_core.receive_with_output_sink( + share.ticket, + sink_for_worker, + Some("receiver".to_string()), + ) + }); + + let request = wait_for_receiver_request(&sender.core, share.transfer_id); + sender + .core + .respond_receiver_request(request.id, true, None) + .unwrap(); + let started = Instant::now(); + while !output_sink.has_started("slow.bin") { + assert!(started.elapsed() < Duration::from_secs(15)); + std::thread::sleep(Duration::from_millis(10)); + } + receiver.core.cancel_transfer(share.transfer_id).unwrap(); + assert!(worker.join().unwrap().is_err()); + assert_eq!(output_sink.terminal_state("slow.bin"), Some("aborted")); +} diff --git a/crates/vnidrop/tests/support/mod.rs b/crates/vnidrop/tests/support/mod.rs new file mode 100644 index 0000000..197b15a --- /dev/null +++ b/crates/vnidrop/tests/support/mod.rs @@ -0,0 +1,262 @@ +// Cargo compiles every file in `tests/` as a separate crate, and each scenario +// intentionally uses only a subset of this shared harness. +#![allow(dead_code)] + +use std::{ + collections::HashMap, + ops::Deref, + path::Path, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use vnidrop::{ + CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, + ShareResult, ShareSource, SourceKind, VnidropCore, VnidropError, +}; + +#[derive(Default)] +pub struct RecordingSink { + events: Mutex>, +} + +impl CoreEventSink for RecordingSink { + fn on_event(&self, event: CoreEvent) { + self.events.lock().unwrap().push(event); + } +} + +impl RecordingSink { + pub fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +pub struct CoreGuard(Arc); + +impl CoreGuard { + pub fn start(path: &Path, sink: Arc) -> Self { + Self( + VnidropCore::initialize(path.to_string_lossy().to_string(), sink) + .expect("test core should initialize"), + ) + } + + pub fn start_with_limits( + path: &Path, + sink: Arc, + limits: CoreLimits, + ) -> Self { + Self( + VnidropCore::initialize_with_limits(path.to_string_lossy().to_string(), sink, limits) + .expect("test core should initialize with limits"), + ) + } + + pub fn arc(&self) -> Arc { + self.0.clone() + } +} + +impl Deref for CoreGuard { + type Target = VnidropCore; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Drop for CoreGuard { + fn drop(&mut self) { + self.0.shutdown(); + } +} + +pub struct TestNode { + _data_dir: tempfile::TempDir, + pub core: CoreGuard, + pub sink: Arc, +} + +impl TestNode { + pub fn new() -> Self { + let data_dir = tempfile::tempdir().unwrap(); + let sink = Arc::new(RecordingSink::default()); + let core = CoreGuard::start(data_dir.path(), sink.clone()); + Self { + _data_dir: data_dir, + core, + sink, + } + } +} + +#[derive(Default)] +pub struct MemoryOutputSink { + files: Mutex>>, + terminal: Mutex>, + fail_writes: bool, + write_delay: Duration, +} + +impl MemoryOutputSink { + pub fn failing_writes() -> Self { + Self { + files: Mutex::new(HashMap::new()), + terminal: Mutex::new(HashMap::new()), + fail_writes: true, + write_delay: Duration::ZERO, + } + } + + pub fn slow_writes(delay: Duration) -> Self { + Self { + files: Mutex::new(HashMap::new()), + terminal: Mutex::new(HashMap::new()), + fail_writes: false, + write_delay: delay, + } + } + + pub fn file(&self, relative_path: &str) -> Vec { + self.files.lock().unwrap()[relative_path].clone() + } + + pub fn terminal_state(&self, relative_path: &str) -> Option<&'static str> { + self.terminal.lock().unwrap().get(relative_path).copied() + } + + pub fn has_started(&self, relative_path: &str) -> bool { + self.files.lock().unwrap().contains_key(relative_path) + } +} + +impl ReceiveOutputSink for MemoryOutputSink { + fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().insert(relative_path, Vec::new()); + Ok(()) + } + + fn write_chunk(&self, relative_path: String, bytes: Vec) -> Result<(), VnidropError> { + if !self.write_delay.is_zero() { + std::thread::sleep(self.write_delay); + } + if self.fail_writes { + return Err(VnidropError::Filesystem { + reason: "sink write failed".to_string(), + }); + } + self.files + .lock() + .unwrap() + .get_mut(&relative_path) + .expect("file was not started") + .extend(bytes); + Ok(()) + } + + fn finish_file(&self, relative_path: String) -> Result<(), VnidropError> { + self.terminal + .lock() + .unwrap() + .insert(relative_path, "finished"); + Ok(()) + } + + fn abort_file(&self, relative_path: String, _reason: String) -> Result<(), VnidropError> { + self.files.lock().unwrap().remove(&relative_path); + self.terminal + .lock() + .unwrap() + .insert(relative_path, "aborted"); + Ok(()) + } +} + +pub fn share_path( + sender: &VnidropCore, + source: &Path, + transfer_id: u64, + display_name: &str, + is_directory: bool, +) -> ShareResult { + sender + .share_files( + vec![ShareSource { + kind: SourceKind::Path, + value: source.to_string_lossy().to_string(), + display_name: Some(display_name.to_string()), + is_directory, + }], + ShareMetadataInput { + transfer_id, + transfer_name: Some(display_name.to_string()), + sender_name: Some("sender".to_string()), + }, + ) + .expect("test share should be created") +} + +pub fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest { + let started = Instant::now(); + loop { + let requests = sender.list_receiver_requests(transfer_id).unwrap(); + if let Some(request) = requests + .into_iter() + .find(|request| request.status == "requested") + { + return request; + } + assert!( + started.elapsed() < Duration::from_secs(15), + "timed out waiting for receiver request" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +pub fn receive_with_response( + sender: &VnidropCore, + transfer_id: u64, + receiver: Arc, + ticket: String, + output_dir: &Path, + accepted: bool, +) -> Result<(), String> { + let output_dir = output_dir.to_string_lossy().to_string(); + let handle = std::thread::spawn(move || { + receiver + .receive(ticket, output_dir, Some("receiver".to_string())) + .map_err(|error| error.to_string()) + }); + respond_to_pending_request(sender, transfer_id, accepted); + handle.join().unwrap() +} + +pub fn receive_with_sink_response( + sender: &VnidropCore, + transfer_id: u64, + receiver: Arc, + ticket: String, + output_sink: Arc, + accepted: bool, +) -> Result<(), String> { + let handle = std::thread::spawn(move || { + receiver + .receive_with_output_sink(ticket, output_sink, Some("receiver".to_string())) + .map_err(|error| error.to_string()) + }); + respond_to_pending_request(sender, transfer_id, accepted); + handle.join().unwrap() +} + +fn respond_to_pending_request(sender: &VnidropCore, transfer_id: u64, accepted: bool) { + let request = wait_for_receiver_request(sender, transfer_id); + sender + .respond_receiver_request( + request.id, + accepted, + (!accepted).then(|| "sender-refused".to_string()), + ) + .unwrap(); +} diff --git a/crates/vnidrop/tests/transfer.rs b/crates/vnidrop/tests/transfer.rs new file mode 100644 index 0000000..d8af022 --- /dev/null +++ b/crates/vnidrop/tests/transfer.rs @@ -0,0 +1,104 @@ +mod support; + +use support::{receive_with_response, share_path, TestNode}; + +#[test] +fn transfers_file_between_two_cores() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("hello.txt"); + std::fs::write(&source_path, b"hello from vnidrop").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + + let share = share_path(&sender.core, &source_path, 7, "hello.txt", false); + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("hello.txt")).unwrap(), + b"hello from vnidrop" + ); + let received = receiver + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 7) + .unwrap(); + assert!(!received.local_id.is_empty()); + assert_eq!( + received.peer_id.as_deref(), + Some(sender.core.status().endpoint_id.as_str()) + ); +} + +#[test] +fn transfers_directory_between_two_cores() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_root = source_dir.path().join("photos"); + std::fs::create_dir_all(source_root.join("nested")).unwrap(); + std::fs::write(source_root.join("cover.txt"), b"cover").unwrap(); + std::fs::write(source_root.join("nested/inside.txt"), b"inside").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + + let share = share_path(&sender.core, &source_root, 8, "photos", true); + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("photos/cover.txt")).unwrap(), + b"cover" + ); + assert_eq!( + std::fs::read(output_dir.path().join("photos/nested/inside.txt")).unwrap(), + b"inside" + ); +} + +#[test] +fn receive_refuses_to_overwrite_existing_destination() { + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("existing.txt"); + let output_path = output_dir.path().join("existing.txt"); + std::fs::write(&source_path, b"new content").unwrap(); + std::fs::write(&output_path, b"keep content").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 27, "existing.txt", false); + + assert!(receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_dir.path(), + true, + ) + .is_err()); + assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content"); + assert!(std::fs::read_dir(output_dir.path()) + .unwrap() + .all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".part"))); +}