mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
test(core): organize hardening coverage and CI
This commit is contained in:
@@ -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::<Vec<_>>()
|
||||
.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;
|
||||
|
||||
56
crates/vnidrop/src/tests/access_policy.rs
Normal file
56
crates/vnidrop/src/tests/access_policy.rs
Normal file
@@ -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"
|
||||
}
|
||||
);
|
||||
}
|
||||
233
crates/vnidrop/src/tests/filesystem.rs
Normal file
233
crates/vnidrop/src/tests/filesystem.rs
Normal file
@@ -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());
|
||||
}
|
||||
13
crates/vnidrop/src/tests/handshake.rs
Normal file
13
crates/vnidrop/src/tests/handshake.rs
Normal file
@@ -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::<HandshakeResponse>(payload).is_err());
|
||||
}
|
||||
}
|
||||
15
crates/vnidrop/src/tests/limits.rs
Normal file
15
crates/vnidrop/src/tests/limits.rs
Normal file
@@ -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());
|
||||
}
|
||||
516
crates/vnidrop/src/tests/repository.rs
Normal file
516
crates/vnidrop/src/tests/repository.rs
Normal file
@@ -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};
|
||||
143
crates/vnidrop/src/tests/runtime.rs
Normal file
143
crates/vnidrop/src/tests/runtime.rs
Normal file
@@ -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();
|
||||
}
|
||||
22
crates/vnidrop/src/tests/secret.rs
Normal file
22
crates/vnidrop/src/tests/secret.rs
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
134
crates/vnidrop/src/tests/ticket.rs
Normal file
134
crates/vnidrop/src/tests/ticket.rs
Normal file
@@ -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::<Vec<_>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
31
crates/vnidrop/src/tests/transfer_state.rs
Normal file
31
crates/vnidrop/src/tests/transfer_state.rs
Normal file
@@ -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));
|
||||
}
|
||||
44
crates/vnidrop/tests/README.md
Normal file
44
crates/vnidrop/tests/README.md
Normal file
@@ -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.
|
||||
130
crates/vnidrop/tests/approval.rs
Normal file
130
crates/vnidrop/tests/approval.rs
Normal file
@@ -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());
|
||||
}
|
||||
251
crates/vnidrop/tests/lifecycle.rs
Normal file
251
crates/vnidrop/tests/lifecycle.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<Vec<CoreEvent>>,
|
||||
}
|
||||
|
||||
impl CoreEventSink for RecordingSink {
|
||||
fn on_event(&self, event: CoreEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingSink {
|
||||
fn events(&self) -> Vec<CoreEvent> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryOutputSink {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
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<u8>) -> 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<u8> {
|
||||
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<VnidropCore>,
|
||||
ticket: String,
|
||||
output_dir: String,
|
||||
receiver_name: Option<String>,
|
||||
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<VnidropCore>,
|
||||
ticket: String,
|
||||
output_sink: Arc<dyn ReceiveOutputSink>,
|
||||
receiver_name: Option<String>,
|
||||
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();
|
||||
}
|
||||
104
crates/vnidrop/tests/output_sink.rs
Normal file
104
crates/vnidrop/tests/output_sink.rs
Normal file
@@ -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"));
|
||||
}
|
||||
262
crates/vnidrop/tests/support/mod.rs
Normal file
262
crates/vnidrop/tests/support/mod.rs
Normal file
@@ -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<Vec<CoreEvent>>,
|
||||
}
|
||||
|
||||
impl CoreEventSink for RecordingSink {
|
||||
fn on_event(&self, event: CoreEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingSink {
|
||||
pub fn events(&self) -> Vec<CoreEvent> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CoreGuard(Arc<VnidropCore>);
|
||||
|
||||
impl CoreGuard {
|
||||
pub fn start(path: &Path, sink: Arc<dyn CoreEventSink>) -> 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<dyn CoreEventSink>,
|
||||
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<VnidropCore> {
|
||||
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<RecordingSink>,
|
||||
}
|
||||
|
||||
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<HashMap<String, Vec<u8>>>,
|
||||
terminal: Mutex<HashMap<String, &'static str>>,
|
||||
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<u8> {
|
||||
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<u8>) -> 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<VnidropCore>,
|
||||
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<VnidropCore>,
|
||||
ticket: String,
|
||||
output_sink: Arc<dyn ReceiveOutputSink>,
|
||||
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();
|
||||
}
|
||||
104
crates/vnidrop/tests/transfer.rs
Normal file
104
crates/vnidrop/tests/transfer.rs
Normal file
@@ -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")));
|
||||
}
|
||||
Reference in New Issue
Block a user