mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-13 05:49:57 +02:00
test(core): add saved devices production release gate
This commit is contained in:
2
.github/workflows/rust-core.yml
vendored
2
.github/workflows/rust-core.yml
vendored
@@ -45,6 +45,8 @@ jobs:
|
|||||||
run: rustup component add clippy rustfmt
|
run: rustup component add clippy rustfmt
|
||||||
- name: Check Rust core
|
- name: Check Rust core
|
||||||
run: make check-rust
|
run: make check-rust
|
||||||
|
- name: Saved devices production release gate
|
||||||
|
run: make test-rust-saved-devices
|
||||||
- name: Install cargo-audit
|
- name: Install cargo-audit
|
||||||
run: cargo install cargo-audit --locked
|
run: cargo install cargo-audit --locked
|
||||||
- name: Audit Rust dependencies
|
- name: Audit Rust dependencies
|
||||||
|
|||||||
7
Makefile
7
Makefile
@@ -10,7 +10,7 @@ include $(ROOT)/make/release.mk
|
|||||||
|
|
||||||
.PHONY: help doctor setup setup-localization setup-docs setup-diagnostics
|
.PHONY: help doctor setup setup-localization setup-docs setup-diagnostics
|
||||||
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
||||||
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink test-rust-saved-devices
|
||||||
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
||||||
.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
||||||
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
|
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
|
||||||
@@ -108,6 +108,11 @@ test-rust-lifecycle: ## Run Rust lifecycle integration tests.
|
|||||||
test-rust-output-sink: ## Run Rust output-sink integration tests.
|
test-rust-output-sink: ## Run Rust output-sink integration tests.
|
||||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test output_sink
|
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test output_sink
|
||||||
|
|
||||||
|
test-rust-saved-devices: ## Run the Saved devices production-core release gate.
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --lib
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test saved_device_domain
|
||||||
|
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test transfer --test approval --test lifecycle --test output_sink
|
||||||
|
|
||||||
check-shared: ## Test and compile the shared Android/JVM module.
|
check-shared: ## Test and compile the shared Android/JVM module.
|
||||||
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS)
|
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS)
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,35 @@ bytes through Kotlin memory.
|
|||||||
5. Only `vnd1:` VniDrop tickets are accepted. Raw iroh `BlobTicket` strings are
|
5. Only `vnd1:` VniDrop tickets are accepted. Raw iroh `BlobTicket` strings are
|
||||||
rejected at parse time so receive always runs the approval handshake.
|
rejected at parse time so receive always runs the approval handshake.
|
||||||
|
|
||||||
|
## Saved Devices And Targeted Transfers
|
||||||
|
|
||||||
|
1. A completed authenticated invitation transfer creates a short-lived pairing
|
||||||
|
eligibility. Both devices must explicitly consent before either relationship
|
||||||
|
becomes `Saved`; decline, forget, block, expiry, or replay cannot save it.
|
||||||
|
2. A saved device's remote display name comes from authenticated transfer
|
||||||
|
metadata and may refresh after later authenticated transfers. A local label
|
||||||
|
is private to this installation, takes display precedence in the UI, and is
|
||||||
|
preserved independently across restart and schema migration.
|
||||||
|
3. `create_targeted_transfer` imports an immutable manifest and sends only a
|
||||||
|
receiver-bound offer. Approval stores protected authorization in core custody;
|
||||||
|
targeted work creates no invitation history, receiver approval request,
|
||||||
|
invitation delivery receipt, received-artifact row, or pairing eligibility.
|
||||||
|
4. Targeted blobs are default-deny and scoped to the intended saved endpoint.
|
||||||
|
Knowing a transfer id, manifest hash, member hash, or blob address does not
|
||||||
|
authorize another device to discover, approve, or download the payload.
|
||||||
|
5. Approved receives use the same streaming, no-overwrite paths, and output-sink
|
||||||
|
terminal-callback guarantees as invitation receives. Verified progress is
|
||||||
|
monotonic and bounded; interrupted work and protected authorization resume
|
||||||
|
after restart without another approval.
|
||||||
|
6. Receiver completion is durable locally and acknowledged to the sender with
|
||||||
|
idempotent retry. Targeted cancel and delete synchronously signal local active
|
||||||
|
work before durable cleanup; forget and block revoke affected relationships
|
||||||
|
and active targeted work within their core operation. All four durably deny
|
||||||
|
reuse and perform idempotent payload/secret cleanup.
|
||||||
|
7. Saved devices, relationships, pairing eligibility, and targeted transfers are
|
||||||
|
shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
|
||||||
|
their existing experimental preference gates is outside this release gate.
|
||||||
|
|
||||||
## Core States And Events
|
## Core States And Events
|
||||||
|
|
||||||
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
|
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
|
||||||
|
|||||||
@@ -623,7 +623,9 @@ impl VnidropCore {
|
|||||||
///
|
///
|
||||||
/// Blocks until the saved receiver approves or declines. On approval the
|
/// Blocks until the saved receiver approves or declines. On approval the
|
||||||
/// receiver stores bound authorization locally via
|
/// receiver stores bound authorization locally via
|
||||||
/// [`Self::respond_to_targeted_offer`].
|
/// [`Self::respond_to_targeted_offer`]. This path creates no invitation
|
||||||
|
/// transfer, receiver approval request, invitation delivery receipt,
|
||||||
|
/// received-artifact record, or pairing eligibility.
|
||||||
pub fn create_targeted_transfer(
|
pub fn create_targeted_transfer(
|
||||||
&self,
|
&self,
|
||||||
receiver_endpoint_id: String,
|
receiver_endpoint_id: String,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use std::{
|
use std::{
|
||||||
path::Path,
|
path::Path,
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
sync::{mpsc, Arc, Mutex},
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
mpsc, Arc, Mutex,
|
||||||
|
},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,11 +31,15 @@ struct EventGateSink {
|
|||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
observed: mpsc::SyncSender<CoreEvent>,
|
observed: mpsc::SyncSender<CoreEvent>,
|
||||||
release: Mutex<mpsc::Receiver<()>>,
|
release: Mutex<mpsc::Receiver<()>>,
|
||||||
|
triggered: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoreEventSink for EventGateSink {
|
impl CoreEventSink for EventGateSink {
|
||||||
fn on_event(&self, event: CoreEvent) {
|
fn on_event(&self, event: CoreEvent) {
|
||||||
if event.phase == "targeted_transfer" && event.kind == self.kind {
|
if event.phase == "targeted_transfer"
|
||||||
|
&& event.kind == self.kind
|
||||||
|
&& !self.triggered.swap(true, Ordering::SeqCst)
|
||||||
|
{
|
||||||
self.observed.send(event).unwrap();
|
self.observed.send(event).unwrap();
|
||||||
self.release.lock().unwrap().recv().unwrap();
|
self.release.lock().unwrap().recv().unwrap();
|
||||||
}
|
}
|
||||||
@@ -284,6 +291,7 @@ fn accepted_event_is_emitted_only_after_receiver_snapshot_is_durable() {
|
|||||||
kind: "approved",
|
kind: "approved",
|
||||||
observed: observed_tx,
|
observed: observed_tx,
|
||||||
release: Mutex::new(release_rx),
|
release: Mutex::new(release_rx),
|
||||||
|
triggered: AtomicBool::new(false),
|
||||||
}));
|
}));
|
||||||
establish_saved(&sender, &receiver, 21_002);
|
establish_saved(&sender, &receiver, 21_002);
|
||||||
|
|
||||||
@@ -344,6 +352,10 @@ fn terminal_events_have_ordered_revisions_and_no_later_progress() {
|
|||||||
assert!(events
|
assert!(events
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.all(|pair| pair[0].revision < pair[1].revision));
|
.all(|pair| pair[0].revision < pair[1].revision));
|
||||||
|
assert!(
|
||||||
|
events.iter().any(|event| event.kind == "progress"),
|
||||||
|
"sizable receive must emit a durable-state progress wake-up"
|
||||||
|
);
|
||||||
let completed = events
|
let completed = events
|
||||||
.iter()
|
.iter()
|
||||||
.position(|event| event.kind == "completed")
|
.position(|event| event.kind == "completed")
|
||||||
@@ -351,15 +363,62 @@ fn terminal_events_have_ordered_revisions_and_no_later_progress() {
|
|||||||
assert!(events[completed + 1..]
|
assert!(events[completed + 1..]
|
||||||
.iter()
|
.iter()
|
||||||
.all(|event| event.kind != "progress"));
|
.all(|event| event.kind != "progress"));
|
||||||
assert_eq!(
|
let completed_snapshot = receiver
|
||||||
receiver
|
.core()
|
||||||
.core()
|
.get_targeted_transfer(transfer.id)
|
||||||
.get_targeted_transfer(transfer.id)
|
.unwrap()
|
||||||
.unwrap()
|
.unwrap();
|
||||||
.unwrap()
|
assert_eq!(completed_snapshot.state, TargetedTransferState::Completed);
|
||||||
.state,
|
assert_eq!(completed_snapshot.verified_bytes, transfer.total_size);
|
||||||
TargetedTransferState::Completed
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_wakeup_exposes_monotonic_bounded_durable_snapshot() {
|
||||||
|
let (observed_tx, observed_rx) = mpsc::sync_channel(1);
|
||||||
|
let (release_tx, release_rx) = mpsc::sync_channel(1);
|
||||||
|
let sender = ProtectedNode::new();
|
||||||
|
let receiver = ProtectedNode::with_sink(Arc::new(EventGateSink {
|
||||||
|
kind: "progress",
|
||||||
|
observed: observed_tx,
|
||||||
|
release: Mutex::new(release_rx),
|
||||||
|
triggered: AtomicBool::new(false),
|
||||||
|
}));
|
||||||
|
establish_saved(&sender, &receiver, 21_008);
|
||||||
|
let transfer = approve(
|
||||||
|
&sender,
|
||||||
|
&receiver,
|
||||||
|
"progress.bin",
|
||||||
|
&vec![9; 2 * 1024 * 1024],
|
||||||
);
|
);
|
||||||
|
let output = tempfile::tempdir().unwrap();
|
||||||
|
let receiver_core = receiver.core();
|
||||||
|
let transfer_id = transfer.id.clone();
|
||||||
|
let output_path = output.path().to_string_lossy().into_owned();
|
||||||
|
let receive = std::thread::spawn(move || {
|
||||||
|
receiver_core.receive_targeted_transfer(transfer_id, output_path)
|
||||||
|
});
|
||||||
|
|
||||||
|
observed_rx
|
||||||
|
.recv_timeout(Duration::from_secs(20))
|
||||||
|
.expect("progress wake-up");
|
||||||
|
let progress = receiver
|
||||||
|
.core()
|
||||||
|
.get_targeted_transfer(transfer.id.clone())
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(progress.verified_bytes > 0);
|
||||||
|
assert!(progress.verified_bytes <= progress.total_size);
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
receive.join().unwrap().unwrap();
|
||||||
|
|
||||||
|
let completed = receiver
|
||||||
|
.core()
|
||||||
|
.get_targeted_transfer(transfer.id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(completed.state, TargetedTransferState::Completed);
|
||||||
|
assert_eq!(completed.verified_bytes, completed.total_size);
|
||||||
|
assert!(completed.verified_bytes >= progress.verified_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -416,9 +416,37 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
|
|||||||
establish_saved(&alice, &bob, 10_020);
|
establish_saved(&alice, &bob, 10_020);
|
||||||
let alice_invitation_count = alice.core().list_transfers().unwrap().len();
|
let alice_invitation_count = alice.core().list_transfers().unwrap().len();
|
||||||
let bob_invitation_count = bob.core().list_transfers().unwrap().len();
|
let bob_invitation_count = bob.core().list_transfers().unwrap().len();
|
||||||
|
let alice_invitation_requests = alice
|
||||||
|
.core()
|
||||||
|
.list_receiver_requests(10_020)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|request| (request.id, request.status, request.completed_at))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let bob_invitation_requests = bob
|
||||||
|
.core()
|
||||||
|
.list_receiver_requests(10_020)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|request| (request.id, request.status, request.completed_at))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let alice_eligibility_count = alice.core().list_pairing_eligibilities().unwrap().len();
|
let alice_eligibility_count = alice.core().list_pairing_eligibilities().unwrap().len();
|
||||||
let bob_eligibility_count = bob.core().list_pairing_eligibilities().unwrap().len();
|
let bob_eligibility_count = bob.core().list_pairing_eligibilities().unwrap().len();
|
||||||
let bob_artifacts_before = bob.core().list_received_artifacts().unwrap();
|
let bob_artifacts_before = bob.core().list_received_artifacts().unwrap();
|
||||||
|
let alice_delivery_events = alice
|
||||||
|
.core()
|
||||||
|
.list_events(None)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.phase == "delivery")
|
||||||
|
.count();
|
||||||
|
let bob_delivery_events = bob
|
||||||
|
.core()
|
||||||
|
.list_events(None)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.phase == "delivery")
|
||||||
|
.count();
|
||||||
|
|
||||||
let source_dir = tempfile::tempdir().unwrap();
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
let source_path = source_dir.path().join("payload.txt");
|
let source_path = source_dir.path().join("payload.txt");
|
||||||
@@ -499,6 +527,22 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
|
|||||||
std::fs::read(output.path().join("payload.txt")).unwrap(),
|
std::fs::read(output.path().join("payload.txt")).unwrap(),
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
|
let completion_started = Instant::now();
|
||||||
|
loop {
|
||||||
|
if alice
|
||||||
|
.core()
|
||||||
|
.get_targeted_transfer(transfer_id.clone())
|
||||||
|
.unwrap()
|
||||||
|
.is_some_and(|row| row.state == TargetedTransferState::Completed)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
completion_started.elapsed() < Duration::from_secs(10),
|
||||||
|
"sender never durably acknowledged targeted completion"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
alice.core().list_transfers().unwrap().len(),
|
alice.core().list_transfers().unwrap().len(),
|
||||||
alice_invitation_count
|
alice_invitation_count
|
||||||
@@ -516,8 +560,50 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
|
|||||||
bob_eligibility_count
|
bob_eligibility_count
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bob.core().list_received_artifacts().unwrap().len(),
|
alice
|
||||||
bob_artifacts_before.len()
|
.core()
|
||||||
|
.list_receiver_requests(10_020)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|request| (request.id, request.status, request.completed_at))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
alice_invitation_requests,
|
||||||
|
"targeted receive must not create sender invitation approval requests"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bob.core()
|
||||||
|
.list_receiver_requests(10_020)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|request| (request.id, request.status, request.completed_at))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
bob_invitation_requests,
|
||||||
|
"targeted receive must not create receiver invitation requests"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bob.core().list_received_artifacts().unwrap(),
|
||||||
|
bob_artifacts_before
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
alice
|
||||||
|
.core()
|
||||||
|
.list_events(None)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.phase == "delivery")
|
||||||
|
.count(),
|
||||||
|
alice_delivery_events,
|
||||||
|
"targeted completion must not emit invitation delivery receipts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bob.core()
|
||||||
|
.list_events(None)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.phase == "delivery")
|
||||||
|
.count(),
|
||||||
|
bob_delivery_events,
|
||||||
|
"targeted receive must not emit invitation delivery events"
|
||||||
);
|
);
|
||||||
|
|
||||||
let charlie_output = tempfile::tempdir().unwrap();
|
let charlie_output = tempfile::tempdir().unwrap();
|
||||||
@@ -531,6 +617,49 @@ fn explicit_approval_gates_content_and_binds_authorization_to_receiver() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrelated_endpoint_cannot_discover_or_approve_another_receivers_offer() {
|
||||||
|
let alice = ProtectedNode::new();
|
||||||
|
let bob = ProtectedNode::new();
|
||||||
|
let charlie = ProtectedNode::new();
|
||||||
|
let bob_id = bob.core().status().endpoint_id.clone();
|
||||||
|
establish_saved(&alice, &bob, 10_024);
|
||||||
|
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("private.txt");
|
||||||
|
std::fs::write(&source_path, b"only Bob may approve").unwrap();
|
||||||
|
let alice_core = alice.core().clone();
|
||||||
|
let create = std::thread::spawn(move || {
|
||||||
|
alice_core.create_targeted_transfer(
|
||||||
|
bob_id,
|
||||||
|
vec![targeted_source(&source_path)],
|
||||||
|
Some("private.txt".to_string()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let offer = wait_for_pending_offer(&bob.core());
|
||||||
|
|
||||||
|
assert!(charlie.core().list_pending_targeted_offers().is_empty());
|
||||||
|
assert!(charlie.core().list_targeted_transfers().unwrap().is_empty());
|
||||||
|
let forged = charlie
|
||||||
|
.core()
|
||||||
|
.respond_to_targeted_offer(offer.transfer_id.clone(), true);
|
||||||
|
assert!(
|
||||||
|
forged.is_err(),
|
||||||
|
"an unrelated endpoint must not approve Bob's offer"
|
||||||
|
);
|
||||||
|
assert!(charlie.core().list_pending_targeted_offers().is_empty());
|
||||||
|
assert!(charlie.core().list_targeted_transfers().unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
bob.core().list_pending_targeted_offers(),
|
||||||
|
vec![offer.clone()]
|
||||||
|
);
|
||||||
|
|
||||||
|
bob.core()
|
||||||
|
.respond_to_targeted_offer(offer.transfer_id, false)
|
||||||
|
.unwrap();
|
||||||
|
assert!(create.join().unwrap().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unrelated_endpoint_cannot_fetch_a_leaked_targeted_blob_ticket() {
|
fn unrelated_endpoint_cannot_fetch_a_leaked_targeted_blob_ticket() {
|
||||||
let alice = ProtectedNode::new();
|
let alice = ProtectedNode::new();
|
||||||
|
|||||||
@@ -137,6 +137,61 @@ fn approval_required_denies_then_allows_receiver() {
|
|||||||
.any(|request| request.status == "completed"));
|
.any(|request| request.status == "completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_required_share_authorizes_multiple_receivers_independently() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let first_output = tempfile::tempdir().unwrap();
|
||||||
|
let second_denied_output = tempfile::tempdir().unwrap();
|
||||||
|
let second_allowed_output = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("private-multi.txt");
|
||||||
|
std::fs::write(&source_path, b"independent receiver approval").unwrap();
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let first_receiver = TestNode::new();
|
||||||
|
let second_receiver = TestNode::new();
|
||||||
|
let share = share_path(&sender.core, &source_path, 31, "private-multi.txt", false);
|
||||||
|
|
||||||
|
receive_with_response(
|
||||||
|
&sender.core,
|
||||||
|
share.transfer_id,
|
||||||
|
first_receiver.core.arc(),
|
||||||
|
share.ticket.clone(),
|
||||||
|
first_output.path(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(receive_with_response(
|
||||||
|
&sender.core,
|
||||||
|
share.transfer_id,
|
||||||
|
second_receiver.core.arc(),
|
||||||
|
share.ticket.clone(),
|
||||||
|
second_denied_output.path(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
receive_with_response(
|
||||||
|
&sender.core,
|
||||||
|
share.transfer_id,
|
||||||
|
second_receiver.core.arc(),
|
||||||
|
share.ticket,
|
||||||
|
second_allowed_output.path(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(first_output.path().join("private-multi.txt")).unwrap(),
|
||||||
|
b"independent receiver approval"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(second_allowed_output.path().join("private-multi.txt")).unwrap(),
|
||||||
|
b"independent receiver approval"
|
||||||
|
);
|
||||||
|
assert!(!second_denied_output
|
||||||
|
.path()
|
||||||
|
.join("private-multi.txt")
|
||||||
|
.exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn receiver_can_cancel_while_waiting_for_approval() {
|
fn receiver_can_cancel_while_waiting_for_approval() {
|
||||||
let source_dir = tempfile::tempdir().unwrap();
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user