use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use offline_protocol_data::{DataValue, VersionToken};
use offline_protocol_transport::{MockTransport, Transport, TransportType};
use crate::constants::ACK_FOR_KEY;
use crate::mls::InMemoryStorage;
use crate::protocol::data::TombstoneOutcome;
use crate::protocol::data_sync::{
blob_digest, MAX_DOCS_PER_SPACE, MAX_QUARANTINED_BLOBS, MAX_REMOVAL_FLOOR_BYTES,
};
use crate::protocol::tests::{create_test_config_for_user, id};
use crate::protocol::types::{storage_keys, SessionState};
use crate::protocol::{OfflineProtocol, TestProtocolStateStorage};
use crate::{ProtocolStateError, ProtocolStateResult, ProtocolStateStorage};
use offline_protocol_mls::MlsStorage;
struct Node {
protocol: OfflineProtocol,
transport: MockTransport,
address: String,
label: String,
secure: Arc<InMemoryStorage>,
state: Arc<InMemoryStorage>,
events: Arc<Mutex<Vec<crate::Event>>>,
}
impl Node {
fn new(label: &str) -> Self {
Node::with_state_storage(label, |state| {
Arc::new(TestProtocolStateStorage { storage: state })
})
}
fn with_state_storage(
label: &str,
wrap: impl FnOnce(Arc<InMemoryStorage>) -> Arc<dyn ProtocolStateStorage>,
) -> Self {
let mut config = create_test_config_for_user(label);
config.encryption.enabled = true;
config.data.enabled = true;
let mut protocol = OfflineProtocol::new(config).expect("protocol");
let secure = crate::test_identity::seeded_storage(label);
let state = Arc::new(InMemoryStorage::new());
protocol
.initialize_mls(secure.clone(), wrap(state.clone()))
.expect("initialize_mls");
let mock = MockTransport::new(TransportType::BLE);
mock.start().expect("transport start");
let transport = mock.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::BLE, Box::new(mock));
let events: Arc<Mutex<Vec<crate::Event>>> = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
protocol.on_event(move |event| sink.lock().unwrap().push(event));
protocol.start().expect("start");
let address = id(label);
Self {
protocol,
transport,
address,
label: label.to_string(),
secure,
state,
events,
}
}
fn restart(&mut self, peer: &str) {
let mut config = create_test_config_for_user(&self.label);
config.encryption.enabled = true;
config.data.enabled = true;
let mut protocol = OfflineProtocol::new(config).expect("protocol");
protocol
.initialize_mls(
self.secure.clone(),
Arc::new(TestProtocolStateStorage {
storage: self.state.clone(),
}),
)
.expect("initialize_mls");
protocol.peer_data_sync.insert(peer.to_string());
protocol.peer_data_media.insert(peer.to_string());
protocol.peer_data_tombstones.insert(peer.to_string());
protocol.peer_data_interest.insert(peer.to_string());
let mock = MockTransport::new(TransportType::BLE);
mock.start().expect("transport start");
self.transport = mock.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::BLE, Box::new(mock));
let events: Arc<Mutex<Vec<crate::Event>>> = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
protocol.on_event(move |event| sink.lock().unwrap().push(event));
protocol.start().expect("start");
self.protocol = protocol;
self.events = events;
}
fn space_for(peer: &Node) -> String {
peer.address.clone()
}
fn delta_records(&self, space: &str, doc: &str) -> usize {
let prefix = format!("{space}/{doc}/");
self.state
.list_keys(storage_keys::DATA_DELTA_LOG)
.unwrap_or_default()
.iter()
.filter(|key| key.starts_with(&prefix))
.count()
}
}
fn pair() -> (Node, Node) {
pair_of(Node::new("alice"), Node::new("bob"))
}
fn pair_of(mut alice: Node, mut bob: Node) -> (Node, Node) {
let bob_kp = {
let manager = bob.protocol.mls_manager.as_ref().unwrap().read().unwrap();
manager.get_or_create_key_package().unwrap()
};
let welcome = {
let manager = alice.protocol.mls_manager.as_ref().unwrap().read().unwrap();
manager
.import_key_package(&bob.address, &bob_kp.key_package_data)
.unwrap();
manager.create_session(&bob.address).unwrap()
};
{
let manager = bob.protocol.mls_manager.as_ref().unwrap().read().unwrap();
manager.join_session(&welcome).unwrap();
}
confirm(&mut alice, &bob.address);
confirm(&mut bob, &alice.address);
alice.protocol.peer_data_sync.insert(bob.address.clone());
bob.protocol.peer_data_sync.insert(alice.address.clone());
alice.protocol.peer_data_media.insert(bob.address.clone());
bob.protocol.peer_data_media.insert(alice.address.clone());
alice
.protocol
.peer_data_tombstones
.insert(bob.address.clone());
bob.protocol
.peer_data_tombstones
.insert(alice.address.clone());
alice
.protocol
.peer_data_interest
.insert(bob.address.clone());
bob.protocol
.peer_data_interest
.insert(alice.address.clone());
(alice, bob)
}
fn confirm(node: &mut Node, peer: &str) {
node.protocol.record_encryption_capable(peer);
node.protocol
.persist_session_state(peer, SessionState::Confirmed, "test")
.unwrap();
node.protocol.confirmed_sessions.insert(peer.to_string());
}
fn pump(from: &mut Node, to: &mut Node) -> usize {
let messages = from.transport.sent_messages();
from.transport.clear_sent_messages();
let moved = messages.len();
for message in messages {
to.transport.queue_message(message);
}
while to.protocol.receive_message().is_some() {}
moved
}
fn settle(alice: &mut Node, bob: &mut Node) -> Vec<usize> {
let mut rounds = Vec::new();
for _ in 0..8 {
let carried = pump(alice, bob) + pump(bob, alice);
rounds.push(carried);
if carried == 0 {
break;
}
}
rounds
}
fn settle_media(alice: &mut Node, bob: &mut Node) -> usize {
for round in 0..400 {
alice.protocol.pump_media_transfers();
bob.protocol.pump_media_transfers();
if pump(alice, bob) + pump(bob, alice) == 0 {
return round;
}
}
400
}
fn write(node: &mut Node, space: &str, doc: &str, key: &str, value: &str) {
node.protocol
.data_map_set(space, doc, "m", key, DataValue::text(value))
.expect("set");
node.protocol.data_flush(space, doc).expect("flush");
}
fn read(node: &mut Node, space: &str, doc: &str, key: &str) -> Option<DataValue> {
node.protocol
.data_map_get(space, doc, "m", key)
.expect("get")
}
fn sync_record(node: &mut Node, space: &str) -> serde_json::Value {
let storage = node
.protocol
.data_storage_for_sync()
.expect("the data layer is on");
let bytes = node
.protocol
.read_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space)
.expect("read the replication bookkeeping")
.expect("the replication bookkeeping exists");
serde_json::from_slice(&bytes).expect("the record is JSON")
}
fn seed_sync_record(node: &mut Node, space: &str, record: &serde_json::Value) {
let storage = node
.protocol
.data_storage_for_sync()
.expect("the data layer is on");
let bytes = serde_json::to_vec(record).expect("encode");
node.protocol
.write_state_record(storage.as_ref(), storage_keys::DATA_SYNC, space, &bytes)
.expect("seed the replication bookkeeping");
}
fn quarantined(node: &mut Node, space: &str) -> Vec<String> {
sync_record(node, space)
.get("quarantined")
.and_then(|value| value.as_array())
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn snapshot_frame(doc: &str, blob: &[u8]) -> String {
format!(
r#"{{"v":1,"k":"snap","doc":"{doc}","blob":"{}"}}"#,
BASE64.encode(blob)
)
}
#[test]
fn a_local_change_reaches_the_other_replica() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
assert!(
!alice.transport.sent_messages().is_empty(),
"a flushed change produced no frame"
);
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "title"),
Some(DataValue::text("hello")),
"the change never arrived"
);
}
#[test]
fn edits_made_on_both_sides_while_apart_converge_on_reconnect() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
bob.protocol
.data_create_doc(&bob_space, "notes")
.expect("create");
write(&mut alice, &alice_space, "notes", "from_alice", "A");
write(&mut bob, &bob_space, "notes", "from_bob", "B");
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
alice.protocol.kick_data_sync(&alice_space, "test");
bob.protocol.kick_data_sync(&bob_space, "test");
let rounds = settle(&mut alice, &mut bob);
for (node, space, label) in [
(&mut alice, alice_space.as_str(), "alice"),
(&mut bob, bob_space.as_str(), "bob"),
] {
assert_eq!(
read(node, space, "notes", "from_alice"),
Some(DataValue::text("A")),
"{label} is missing alice's edit"
);
assert_eq!(
read(node, space, "notes", "from_bob"),
Some(DataValue::text("B")),
"{label} is missing bob's edit"
);
}
assert_eq!(
rounds.last().copied(),
Some(0),
"the exchange never went quiet: {rounds:?}"
);
}
#[test]
fn a_document_the_peer_has_never_seen_arrives_whole() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "recipes", "dish", "soup");
alice.transport.clear_sent_messages();
alice.protocol.kick_data_sync(&alice_space, "test");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "recipes", "dish"),
Some(DataValue::text("soup")),
"a document the peer had never seen did not arrive"
);
}
#[test]
fn nothing_is_sent_to_a_peer_that_has_not_advertised_sync() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
alice.protocol.peer_data_sync.remove(&bob.address);
alice.transport.clear_sent_messages();
write(&mut alice, &alice_space, "notes", "title", "private");
alice.protocol.kick_data_sync(&alice_space, "test");
assert!(
alice.transport.sent_messages().is_empty(),
"a frame was sent to a peer that never asked for one"
);
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "title"),
None,
"the change reached a peer that had not advertised sync"
);
}
#[test]
fn a_change_applied_from_a_peer_is_not_echoed_back() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "title", "hello");
bob.transport.clear_sent_messages();
pump(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &Node::space_for(&alice), "notes", "title"),
Some(DataValue::text("hello")),
"precondition: the change has to have arrived for the echo to exist"
);
let echoed: Vec<_> = bob
.transport
.sent_messages()
.into_iter()
.filter(|message| !message.metadata.contains_key(ACK_FOR_KEY))
.collect();
assert!(
echoed.is_empty(),
"a change was echoed straight back to the peer it came from"
);
}
#[test]
fn a_local_edit_pending_when_a_remote_change_arrives_still_reaches_the_peer() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "seed", "0");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "seed"),
Some(DataValue::text("0")),
"precondition: both replicas have to hold the document"
);
alice
.protocol
.data_map_set(
&alice_space,
"notes",
"m",
"from_alice",
DataValue::text("A"),
)
.expect("set");
write(&mut bob, &bob_space, "notes", "from_bob", "B");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "from_alice"),
Some(DataValue::text("A")),
"the edit that was pending when a remote change arrived never left the device"
);
assert_eq!(
read(&mut alice, &alice_space, "notes", "from_bob"),
Some(DataValue::text("B")),
"draining the local edit cost the import it was making room for"
);
}
struct FailOneDeltaWrite {
inner: TestProtocolStateStorage,
armed: Arc<AtomicBool>,
fired: Arc<AtomicBool>,
}
impl ProtocolStateStorage for FailOneDeltaWrite {
fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> ProtocolStateResult<()> {
if key_type == storage_keys::DATA_DELTA_LOG && self.armed.swap(false, Ordering::SeqCst) {
self.fired.store(true, Ordering::SeqCst);
return Err(ProtocolStateError::StoreFailed(
"the backend is briefly unavailable".to_string(),
));
}
self.inner.store(key_type, key_id, data)
}
fn load(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<Option<Vec<u8>>> {
self.inner.load(key_type, key_id)
}
fn delete(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<()> {
self.inner.delete(key_type, key_id)
}
fn list_keys(&self, key_type: &str) -> ProtocolStateResult<Vec<String>> {
self.inner.list_keys(key_type)
}
}
#[test]
fn a_local_edit_stranded_by_a_failed_pre_flush_is_announced_to_the_peer() {
let armed = Arc::new(AtomicBool::new(false));
let fired = Arc::new(AtomicBool::new(false));
let alice = Node::with_state_storage("alice", |state| {
Arc::new(FailOneDeltaWrite {
inner: TestProtocolStateStorage { storage: state },
armed: armed.clone(),
fired: fired.clone(),
})
});
let (mut alice, mut bob) = pair_of(alice, Node::new("bob"));
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "seed", "0");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "seed"),
Some(DataValue::text("0")),
"precondition: both replicas have to hold the document"
);
alice
.protocol
.data_map_set(
&alice_space,
"notes",
"m",
"from_alice",
DataValue::text("A"),
)
.expect("set");
armed.store(true, Ordering::SeqCst);
write(&mut bob, &bob_space, "notes", "from_bob", "B");
settle(&mut alice, &mut bob);
assert!(
fired.load(Ordering::SeqCst),
"precondition: the pre-flush has to have failed, or this proves nothing \
about what happens when it does"
);
assert_eq!(
read(&mut alice, &alice_space, "notes", "from_bob"),
Some(DataValue::text("B")),
"a failed pre-flush cost the import it was making room for"
);
assert_eq!(
read(&mut bob, &bob_space, "notes", "from_alice"),
Some(DataValue::text("A")),
"the edit stranded by the failed pre-flush was never announced, and \
nothing is left to notice it"
);
}
fn grow_past_cap(node: &mut Node, space: &str, doc: &str) {
let filler = |seed: u32| -> Vec<u8> {
let mut state = 0x9e37_79b9u32.wrapping_mul(seed.wrapping_add(1));
(0..600 * 1024)
.map(|_| {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(state >> 24) as u8
})
.collect()
};
node.protocol
.data_map_set(space, doc, "m", "half", DataValue::bytes(filler(1)))
.expect("set");
node.protocol.data_flush(space, doc).expect("flush");
node.protocol
.data_map_set(space, doc, "m", "other_half", DataValue::bytes(filler(2)))
.expect("set");
let breach = node.protocol.data_flush(space, doc);
assert!(
matches!(breach, Err(crate::error::Error::DocTooLarge { .. })),
"the document answered {breach:?} rather than reaching its cap"
);
}
#[test]
fn a_document_over_its_cap_still_accepts_a_remote_change() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "shared", "0");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "shared"),
Some(DataValue::text("0")),
"precondition: both replicas have to hold the document"
);
alice.protocol.peer_data_sync.remove(&bob.address);
grow_past_cap(&mut alice, &alice_space, "notes");
alice.protocol.peer_data_sync.insert(bob.address.clone());
alice.transport.clear_sent_messages();
alice
.protocol
.data_map_delete(&alice_space, "notes", "m", "shared")
.expect("deletions must keep working past the cap");
assert!(
alice
.protocol
.data_doc_size(&alice_space, "notes")
.expect("size")
> crate::MAX_DOC_BYTES as u64,
"precondition: the document has to still be over its cap with the \
deletion pending, or the pre-flush answers `Ok` and the arm under \
test is never reached"
);
write(&mut bob, &bob_space, "notes", "from_bob", "B");
pump(&mut bob, &mut alice);
assert_eq!(
read(&mut alice, &alice_space, "notes", "from_bob"),
Some(DataValue::text("B")),
"a document over its cap refused a remote change instead of stepping over the flush error"
);
assert_eq!(
alice
.protocol
.data_map_get(&alice_space, "notes", "m", "shared")
.expect("get"),
None,
"the pre-flush did not commit the pending deletion"
);
let pushed: Vec<_> = alice
.transport
.sent_messages()
.into_iter()
.filter(|message| !message.metadata.contains_key(ACK_FOR_KEY))
.collect();
assert!(
!pushed.is_empty(),
"the pending deletion was committed but never sent, so `DocTooLarge` \
does mean the flush was lost"
);
}
#[test]
fn a_local_edit_stranded_over_the_cap_is_announced_to_the_peer() {
let armed = Arc::new(AtomicBool::new(false));
let fired = Arc::new(AtomicBool::new(false));
let alice = Node::with_state_storage("alice", |state| {
Arc::new(FailOneDeltaWrite {
inner: TestProtocolStateStorage { storage: state },
armed: armed.clone(),
fired: fired.clone(),
})
});
let (mut alice, mut bob) = pair_of(alice, Node::new("bob"));
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "shared", "0");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "shared"),
Some(DataValue::text("0")),
"precondition: both replicas have to hold the document"
);
alice.protocol.peer_data_sync.remove(&bob.address);
grow_past_cap(&mut alice, &alice_space, "notes");
alice.protocol.peer_data_sync.insert(bob.address.clone());
alice
.protocol
.data_map_delete(&alice_space, "notes", "m", "shared")
.expect("deletions must keep working past the cap");
assert!(
alice
.protocol
.data_doc_size(&alice_space, "notes")
.expect("size")
> crate::MAX_DOC_BYTES as u64,
"precondition: the document has to still be over its cap with the \
deletion pending"
);
alice.transport.clear_sent_messages();
write(&mut bob, &bob_space, "notes", "from_bob", "B");
armed.store(true, Ordering::SeqCst);
pump(&mut bob, &mut alice);
assert!(
fired.load(Ordering::SeqCst),
"precondition: the pre-flush has to have failed, or this proves nothing \
about what happens when it does"
);
assert_eq!(
read(&mut alice, &alice_space, "notes", "from_bob"),
Some(DataValue::text("B")),
"a document over its cap refused a remote change instead of stepping \
over the flush error"
);
assert_eq!(
alice
.protocol
.data_map_get(&alice_space, "notes", "m", "shared")
.expect("get"),
None,
"precondition: the fold has to have happened, or there is nothing \
stranded to announce"
);
let announced: Vec<_> = alice
.transport
.sent_messages()
.into_iter()
.filter(|message| !message.metadata.contains_key(ACK_FOR_KEY))
.collect();
assert!(
!announced.is_empty(),
"the deletion folded into the import was stranded with no offer, so \
nothing is left to tell the peer to ask"
);
let rounds = settle(&mut alice, &mut bob);
assert_eq!(
rounds.last(),
Some(&0),
"the compensating offer started an exchange that does not end: {rounds:?}"
);
}
#[test]
fn a_replicated_change_survives_a_restart_of_the_receiver() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "durable");
settle(&mut alice, &mut bob);
let stored = bob
.protocol
.data_map_get(&bob_space, "notes", "m", "title")
.expect("get");
assert_eq!(stored, Some(DataValue::text("durable")));
let docs = bob.protocol.data_list_docs(&bob_space).expect("list");
assert!(
docs.contains(&"notes".to_string()),
"the replicated document is not in the space index"
);
}
#[test]
fn a_frame_from_a_peer_only_ever_touches_that_peer_s_space() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
alice
.protocol
.data_map_set("private", "diary", "m", "secret", DataValue::text("mine"))
.expect("set");
alice
.protocol
.data_flush("private", "diary")
.expect("flush");
alice.transport.clear_sent_messages();
alice.protocol.kick_data_sync("private", "test");
assert!(
alice.transport.sent_messages().is_empty(),
"a local-only space produced a frame"
);
write(&mut alice, &alice_space, "notes", "title", "shared");
settle(&mut alice, &mut bob);
assert!(
!bob.protocol
.data_list_spaces()
.expect("spaces")
.contains(&"private".to_string()),
"a private space leaked into the peer's store"
);
}
#[test]
fn rediscovery_flapping_does_not_re_offer_every_time() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "title", "hello");
alice.transport.clear_sent_messages();
for _ in 0..5 {
alice
.protocol
.kick_data_sync(&alice_space, "peer_rediscovered");
}
assert_eq!(
alice.transport.sent_messages().len(),
1,
"five rediscoveries produced more than one offer"
);
}
#[test]
fn the_startup_sweep_sees_a_space_that_has_only_ever_been_written_to() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "title", "unshared");
assert!(
alice
.protocol
.data_list_spaces()
.expect("spaces")
.contains(&alice_space),
"the startup sweep cannot see a space that has only ever been written to"
);
}
fn write_until_compacted(node: &mut Node, space: &str, doc: &str) -> bool {
let filler = "x".repeat(8 * 1024);
for n in 0..32 {
write(node, space, doc, &format!("filler{n}"), &filler);
if node.delta_records(space, doc) == 0 {
return true;
}
}
false
}
#[test]
fn a_fork_below_the_peers_trim_point_converges_instead_of_talking_forever() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "seed", "0");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "seed"),
Some(DataValue::text("0")),
"precondition: the replicas have to share a base for one to fork below it"
);
assert!(
write_until_compacted(&mut alice, &alice_space, "notes"),
"compaction never fired, so there is no trimmed history to fork below"
);
alice.restart(&bob.address);
alice.transport.clear_sent_messages();
write(&mut bob, &bob_space, "notes", "from_bob", "B");
let rounds = settle(&mut alice, &mut bob);
assert_eq!(
rounds.last().copied(),
Some(0),
"the exchange never went quiet: {rounds:?}"
);
assert_eq!(
read(&mut alice, &alice_space, "notes", "seed"),
Some(DataValue::text("0")),
"the refused exchange damaged the document it could not merge"
);
assert_eq!(
read(&mut bob, &bob_space, "notes", "from_bob"),
Some(DataValue::text("B")),
"the peer lost its own edit to a refusal on the other side"
);
}
#[test]
fn a_snapshot_request_for_an_unknown_document_creates_nothing() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
alice.transport.clear_sent_messages();
alice.protocol.handle_data_sync_frame(
&bob.address,
r#"{"v":1,"k":"need_snap","doc":"never-heard-of-it"}"#,
);
assert!(
alice
.protocol
.data_list_docs(&alice_space)
.expect("list")
.is_empty(),
"a snapshot request created a document"
);
assert!(
alice.transport.sent_messages().is_empty(),
"a snapshot request for an unknown document was answered"
);
}
#[test]
fn a_partial_offer_does_not_snapshot_the_documents_it_leaves_out() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "one", "k", "1");
write(&mut alice, &alice_space, "two", "k", "2");
let ours = alice
.protocol
.data_doc_version(&alice_space, "one")
.expect("version");
let offer = |reply: bool, partial: bool| {
format!(
r#"{{"v":1,"k":"vv","reply":{reply},"partial":{partial},"docs":{{"one":"{ours}"}}}}"#
)
};
alice.transport.clear_sent_messages();
alice
.protocol
.handle_data_sync_frame(&bob.address, &offer(true, true));
assert!(
alice.transport.sent_messages().is_empty(),
"a partial offer was read as the complete list, so every document \
missing from it was sent in full"
);
alice
.protocol
.handle_data_sync_frame(&bob.address, &offer(true, false));
assert!(
!alice.transport.sent_messages().is_empty(),
"a complete offer must still carry a document the peer has never seen"
);
}
#[test]
fn a_document_named_only_in_a_reply_is_asked_for_rather_than_left_empty() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut bob, &bob_space, "notes", "k", "from bob");
let theirs = bob
.protocol
.data_doc_version(&bob_space, "notes")
.expect("version");
bob.transport.clear_sent_messages();
alice.transport.clear_sent_messages();
alice.protocol.handle_data_sync_frame(
&bob.address,
&format!(
r#"{{"v":1,"k":"vv","reply":true,"partial":false,"docs":{{"notes":"{theirs}"}}}}"#
),
);
assert!(
!alice.transport.sent_messages().is_empty(),
"the document Alice created from the reply was never asked about, so \
nothing will ever fill it: the peer does not repeat an offer it has \
already answered, and a link that never drops fires no other trigger"
);
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut alice, &alice_space, "notes", "k"),
Some(DataValue::text("from bob")),
"the document stayed empty on the replica that created it"
);
}
#[test]
fn a_document_created_on_the_offering_leg_is_not_asked_for_twice() {
let (mut alice, bob) = pair();
alice.transport.clear_sent_messages();
alice.protocol.handle_data_sync_frame(
&bob.address,
r#"{"v":1,"k":"vv","reply":false,"partial":false,"docs":{"notes":"AA=="}}"#,
);
assert_eq!(
alice.transport.sent_messages().len(),
1,
"the offering leg answers with one counter-offer naming what it \
created; a second frame is the same question asked twice"
);
}
#[test]
fn a_peer_cannot_name_unlimited_documents_into_our_storage() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
let mut sent = 0usize;
while sent < MAX_DOCS_PER_SPACE + 64 {
let docs: Vec<String> = (sent..sent + 128)
.map(|n| format!(r#""doc{n}":"""#))
.collect();
alice.protocol.handle_data_sync_frame(
&bob.address,
&format!(
r#"{{"v":1,"k":"vv","reply":true,"partial":true,"docs":{{{}}}}}"#,
docs.join(",")
),
);
sent += 128;
}
assert_eq!(
alice
.protocol
.data_list_docs(&alice_space)
.expect("list")
.len(),
MAX_DOCS_PER_SPACE,
"a peer talked this device past its document ceiling"
);
alice.protocol.handle_data_sync_frame(
&bob.address,
r#"{"v":1,"k":"delta","doc":"one-more","blob":"AAAA"}"#,
);
assert!(
!alice
.protocol
.data_list_docs(&alice_space)
.expect("list")
.contains(&"one-more".to_string()),
"a blob created a document past the ceiling"
);
}
#[test]
fn a_lost_final_delta_heals_once_local_commits_go_quiet() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "a", "1");
settle(&mut alice, &mut bob);
write(&mut alice, &alice_space, "notes", "b", "2");
alice.transport.clear_sent_messages();
settle(&mut alice, &mut bob);
assert_eq!(read(&mut bob, &bob_space, "notes", "b"), None);
alice.protocol.process().expect("process");
assert!(
alice.transport.sent_messages().is_empty(),
"a settle offer went out before local commits had gone quiet"
);
bring_settle_forward(&mut alice);
alice.protocol.process().expect("process");
assert_eq!(
alice.transport.sent_messages().len(),
1,
"one quiet period must cost exactly one settle offer"
);
let rounds = settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "notes", "b"),
Some(DataValue::text("2")),
"the lost tail of the burst never reached the peer"
);
assert_eq!(
rounds.last(),
Some(&0),
"the settle exchange has to stop (rounds: {rounds:?})"
);
assert!(
alice.protocol.data_sync_settle.is_empty() && bob.protocol.data_sync_settle.is_empty(),
"a settle offer outlived its quiet period, or an import armed one"
);
}
fn bring_settle_forward(node: &mut Node) {
let now = std::time::Instant::now();
for (due, _) in node.protocol.data_sync_settle.values_mut() {
*due = now;
}
}
#[test]
fn a_commit_inside_the_quiet_period_pushes_the_settle_offer_out() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "a", "1");
bring_settle_forward(&mut alice);
write(&mut alice, &alice_space, "todo", "b", "2");
alice.transport.clear_sent_messages();
alice.protocol.process().expect("process");
assert!(
alice.transport.sent_messages().is_empty(),
"a commit inside the quiet period did not push the settle offer out"
);
let (_, docs) = alice
.protocol
.data_sync_settle
.get(&alice_space)
.expect("the pending settle offer was dropped by the second commit");
assert_eq!(
docs.iter().map(String::as_str).collect::<Vec<_>>(),
["notes", "todo"],
"the pushed-out offer must still name every document of the burst"
);
bring_settle_forward(&mut alice);
alice.protocol.process().expect("process");
assert_eq!(alice.transport.sent_messages().len(), 1);
}
#[test]
fn a_settle_offer_does_not_bring_back_a_document_removed_since_its_commit() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "k", "v");
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
alice.transport.clear_sent_messages();
bring_settle_forward(&mut alice);
alice.protocol.process().expect("process");
assert!(
alice.transport.sent_messages().is_empty(),
"a settle offer went out naming only a removed document"
);
assert!(
!alice.protocol.data_holds_doc(&alice_space, "notes"),
"reading a version to settle re-created the removed document"
);
assert!(alice.protocol.data_sync_settle.is_empty());
}
#[test]
fn a_settle_offer_is_not_sent_once_its_space_stops_replicating() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "k", "v");
assert!(
!alice.protocol.data_sync_settle.is_empty(),
"precondition: the commit has to arm a settle offer"
);
alice.protocol.block_user(&bob.address).expect("block");
alice.transport.clear_sent_messages();
bring_settle_forward(&mut alice);
alice.protocol.process().expect("process");
assert!(
alice.transport.sent_messages().is_empty(),
"a settle offer reached a peer the space no longer replicates with"
);
assert!(
alice.protocol.data_sync_settle.is_empty(),
"a settle offer with nowhere to go was kept rather than dropped"
);
}
#[test]
fn a_change_too_large_to_inline_still_tells_the_peer_it_happened() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(
&mut alice,
&alice_space,
"notes",
"huge",
&"x".repeat(48 * 1024),
);
settle(&mut alice, &mut bob);
assert!(
bob.protocol
.data_list_docs(&bob_space)
.expect("list")
.contains(&"notes".to_string()),
"the peer was never told the document exists, so nothing will ask for it"
);
}
#[test]
fn nothing_is_sent_to_a_blocked_peer_and_unblocking_forgets_them() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
alice.protocol.block_user(&bob.address).expect("block");
alice.transport.clear_sent_messages();
write(&mut alice, &alice_space, "notes", "title", "private");
alice.protocol.kick_data_sync(&alice_space, "test");
assert!(
alice.transport.sent_messages().is_empty(),
"a document reached a blocked peer"
);
alice.protocol.unblock_user(&bob.address).expect("unblock");
assert!(
!alice.protocol.peer_data_sync.contains(&bob.address),
"the clean slate kept the sync capability it had just deleted from disk"
);
}
struct RecordingStateStorage {
inner: TestProtocolStateStorage,
writes: Arc<Mutex<Vec<String>>>,
}
impl ProtocolStateStorage for RecordingStateStorage {
fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> ProtocolStateResult<()> {
self.writes.lock().unwrap().push(key_type.to_string());
self.inner.store(key_type, key_id, data)
}
fn load(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<Option<Vec<u8>>> {
self.inner.load(key_type, key_id)
}
fn delete(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<()> {
self.writes.lock().unwrap().push(format!("del:{key_type}"));
self.inner.delete(key_type, key_id)
}
fn list_keys(&self, key_type: &str) -> ProtocolStateResult<Vec<String>> {
self.inner.list_keys(key_type)
}
}
#[test]
fn a_blob_that_did_not_survive_its_last_import_is_refused_when_the_sender_retries_it() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
let blob = alice
.protocol
.data_export_snapshot(&alice_space, "notes")
.expect("snapshot");
let digest = blob_digest(&blob);
seed_sync_record(
&mut bob,
&bob_space,
&serde_json::json!({ "in_flight": digest }),
);
bob.restart(&alice.address);
bob.protocol
.handle_data_sync_frame(&alice.address, &snapshot_frame("notes", &blob));
assert!(
bob.protocol
.data_list_docs(&bob_space)
.expect("list")
.is_empty(),
"the blob that did not survive its last import was handed to the engine again"
);
assert_eq!(
read(&mut bob, &bob_space, "notes", "title"),
None,
"the refused blob applied anyway"
);
assert!(
quarantined(&mut bob, &bob_space).contains(&digest),
"the marker left by the dead run was not promoted to a refusal"
);
assert!(
sync_record(&mut bob, &bob_space).get("in_flight").is_none(),
"the promoted marker was left in flight, so the next open promotes it again"
);
bob.restart(&alice.address);
bob.protocol
.handle_data_sync_frame(&alice.address, &snapshot_frame("notes", &blob));
assert!(
bob.protocol
.data_list_docs(&bob_space)
.expect("list")
.is_empty(),
"the quarantine did not survive a relaunch, so the retry ladder gets another go"
);
write(&mut alice, &alice_space, "notes", "title", "hello again");
let fresh = alice
.protocol
.data_export_snapshot(&alice_space, "notes")
.expect("snapshot");
assert_ne!(
blob_digest(&fresh),
digest,
"precondition: the second blob has to be a different one to prove anything"
);
bob.protocol
.handle_data_sync_frame(&alice.address, &snapshot_frame("notes", &fresh));
assert_eq!(
read(&mut bob, &bob_space, "notes", "title"),
Some(DataValue::text("hello again")),
"a quarantined blob stopped the peer replicating rather than stopping itself"
);
}
#[test]
fn the_in_flight_marker_reaches_disk_before_the_engine_sees_the_blob() {
let mut alice = Node::new("alice");
let sender = alice.address.clone();
let space = id("bob");
write(&mut alice, &space, "notes", "title", "hello");
let blob = alice
.protocol
.data_export_snapshot(&space, "notes")
.expect("snapshot");
let mut config = create_test_config_for_user("bob");
config.encryption.enabled = true;
config.data.enabled = true;
let mut receiver = OfflineProtocol::new(config).expect("protocol");
let writes = Arc::new(Mutex::new(Vec::new()));
receiver
.initialize_mls(
crate::test_identity::seeded_storage("bob"),
Arc::new(RecordingStateStorage {
inner: TestProtocolStateStorage {
storage: Arc::new(InMemoryStorage::new()),
},
writes: writes.clone(),
}),
)
.expect("initialize_mls");
let mock = MockTransport::new(TransportType::BLE);
mock.start().expect("transport start");
receiver
.transport_manager_mut()
.add_transport(TransportType::BLE, Box::new(mock));
receiver.start().expect("start");
writes.lock().unwrap().clear();
receiver.handle_data_sync_frame(&sender, &snapshot_frame("notes", &blob));
assert_eq!(
receiver
.data_map_get(&sender, "notes", "m", "title")
.expect("get"),
Some(DataValue::text("hello")),
"precondition: the blob has to reach the engine for the ordering to mean anything"
);
let order: Vec<String> = writes
.lock()
.unwrap()
.iter()
.filter(|key_type| {
[
storage_keys::DATA_SYNC,
storage_keys::DATA_DOCS,
storage_keys::DATA_DELTA_LOG,
]
.contains(&key_type.as_str())
})
.cloned()
.collect();
assert_eq!(
order.first().map(String::as_str),
Some(storage_keys::DATA_SYNC),
"the document was written before the marker that says an import was \
under way, so a blob that ends the process leaves nothing behind: {order:?}"
);
assert!(
order
.iter()
.any(|key_type| key_type != storage_keys::DATA_SYNC),
"nothing was written for the document, so this proves only that the \
marker came first among no other writes: {order:?}"
);
}
#[test]
fn a_blob_judged_against_a_floor_is_judged_inside_the_in_flight_marker() {
let mut alice = Node::new("alice");
let sender = alice.address.clone();
let space = id("bob");
write(&mut alice, &space, "notes", "title", "hello");
let blob = alice
.protocol
.data_export_snapshot(&space, "notes")
.expect("snapshot");
let floor = VersionToken::from_bytes(
BASE64
.decode(
alice
.protocol
.data_doc_version(&space, "notes")
.expect("version"),
)
.expect("base64"),
);
let (mut receiver, writes) = recording_protocol("bob");
assert_eq!(
receiver
.data_apply_tombstone(&sender, "notes", &floor)
.expect("apply"),
TombstoneOutcome::Recorded
);
writes.lock().unwrap().clear();
receiver.handle_data_sync_frame(&sender, &snapshot_frame("notes", &blob));
assert!(
!receiver
.data_list_docs(&sender)
.expect("list")
.contains(&"notes".to_string()),
"precondition: the floor has to refuse the blob for the marker question to be about the floor"
);
let order: Vec<String> = writes
.lock()
.unwrap()
.iter()
.filter(|key_type| {
[
storage_keys::DATA_SYNC,
storage_keys::DATA_DOCS,
storage_keys::DATA_DELTA_LOG,
]
.contains(&key_type.as_str())
})
.cloned()
.collect();
assert_eq!(
order
.iter()
.filter(|key_type| key_type.as_str() == storage_keys::DATA_SYNC)
.count(),
2,
"a blob was judged against a floor with no in-flight marker written before the \
decode and cleared after it, so one that ends the process there is retried on \
every launch: {order:?}"
);
assert!(
order
.iter()
.all(|key_type| key_type == storage_keys::DATA_SYNC),
"a blob the floor refused still wrote a document record: {order:?}"
);
}
#[test]
fn the_quarantine_forgets_its_oldest_entry_rather_than_growing() {
let (alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let existing: Vec<String> = (0..MAX_QUARANTINED_BLOBS)
.map(|n| format!("{n:032x}"))
.collect();
let newest = "f".repeat(32);
seed_sync_record(
&mut bob,
&bob_space,
&serde_json::json!({ "in_flight": newest, "quarantined": existing }),
);
bob.protocol.handle_data_sync_frame(
&alice.address,
r#"{"v":1,"k":"delta","doc":"notes","blob":"AAAA"}"#,
);
let held = quarantined(&mut bob, &bob_space);
assert_eq!(
held.len(),
MAX_QUARANTINED_BLOBS,
"the quarantine grew past its ceiling"
);
assert!(
!held.contains(&existing[0]),
"the oldest refusal was kept and something else was dropped instead"
);
assert_eq!(
held.last().map(String::as_str),
Some(newest.as_str()),
"the marker left by the previous run is not the newest refusal"
);
}
#[test]
fn a_wipe_takes_the_replication_bookkeeping_with_it() {
let (alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
write(&mut bob, &bob_space, "notes", "k", "v");
seed_sync_record(
&mut bob,
&bob_space,
&serde_json::json!({ "quarantined": [blob_digest(b"something refused")] }),
);
assert!(
!bob.state
.list_keys(storage_keys::DATA_SYNC)
.unwrap_or_default()
.is_empty(),
"precondition: there has to be bookkeeping on disk for a wipe to miss it"
);
assert!(
!bob.protocol.data_sync_settle.is_empty(),
"precondition: the local write has to arm a settle offer"
);
bob.protocol.data_wipe_all().expect("wipe");
assert!(
bob.protocol.data_sync_settle.is_empty(),
"a settle offer outlived the wipe, and would name documents the wipe erased"
);
assert!(
bob.state
.list_keys(storage_keys::DATA_SYNC)
.unwrap_or_default()
.is_empty(),
"the wipe reported success while leaving the peer-keyed replication \
bookkeeping behind"
);
}
struct OneUnreadableDoc {
inner: TestProtocolStateStorage,
doc_suffix: String,
}
impl ProtocolStateStorage for OneUnreadableDoc {
fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> ProtocolStateResult<()> {
self.inner.store(key_type, key_id, data)
}
fn load(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<Option<Vec<u8>>> {
if key_type == storage_keys::DATA_DOCS && key_id.ends_with(&self.doc_suffix) {
return Err(ProtocolStateError::LoadFailed(
"this document cannot be read this session".to_string(),
));
}
self.inner.load(key_type, key_id)
}
fn delete(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<()> {
self.inner.delete(key_type, key_id)
}
fn list_keys(&self, key_type: &str) -> ProtocolStateResult<Vec<String>> {
self.inner.list_keys(key_type)
}
}
#[test]
fn a_document_that_cannot_be_read_is_left_out_of_the_offer_rather_than_failing_it() {
let (mut alice, bob) = pair();
let space = Node::space_for(&bob);
write(&mut alice, &space, "healthy", "k", "v");
write(&mut alice, &space, "broken", "k", "v");
let mut config = create_test_config_for_user("alice");
config.encryption.enabled = true;
config.data.enabled = true;
let mut relaunched = OfflineProtocol::new(config).expect("protocol");
relaunched
.initialize_mls(
alice.secure.clone(),
Arc::new(OneUnreadableDoc {
inner: TestProtocolStateStorage {
storage: alice.state.clone(),
},
doc_suffix: "/broken".to_string(),
}),
)
.expect("initialize_mls");
let versions = relaunched
.data_sync_versions(&space)
.expect("one unreadable document failed the whole space");
assert!(
versions.contains_key("healthy"),
"the readable document was dropped along with the unreadable one"
);
assert!(
!versions.contains_key("broken"),
"precondition: the document has to actually be unreadable, or this \
proves nothing about what happens when one is"
);
}
fn events_named(node: &Node, name: &str) -> Vec<serde_json::Value> {
node.events
.lock()
.unwrap()
.iter()
.filter_map(|event| serde_json::to_value(event).ok())
.filter(|value| value.get("type").and_then(|t| t.as_str()) == Some(name))
.collect()
}
fn clear_events(node: &Node) {
node.events.lock().unwrap().clear();
}
#[test]
fn an_attachment_reference_replicates_without_its_bytes() {
let (mut alice, mut bob) = pair();
let space = Node::space_for(&bob);
let blob = b"the bytes themselves".to_vec();
let hash = OfflineProtocol::data_attachment_hash(&blob);
alice
.protocol
.data_map_set(
&space,
"notes",
"files",
"plan",
DataValue::Attachment {
hash: hash.clone(),
size: blob.len() as u64,
name: Some("plan.txt".to_string()),
mime: None,
},
)
.expect("attach");
alice.protocol.data_flush(&space, "notes").expect("flush");
settle(&mut alice, &mut bob);
let bob_space = Node::space_for(&alice);
let Some(DataValue::Attachment {
hash: seen,
size,
name,
..
}) = bob
.protocol
.data_map_get(&bob_space, "notes", "files", "plan")
.expect("get")
else {
panic!("the reference did not replicate");
};
assert_eq!(seen, hash);
assert_eq!(size, blob.len() as u64);
assert_eq!(name.as_deref(), Some("plan.txt"));
assert!(events_named(&bob, "file_received").is_empty());
assert!(events_named(&bob, "data_attachment_received").is_empty());
}
#[test]
fn a_fetch_travels_the_media_path_and_never_looks_like_a_file() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
let blob = b"a blob worth fetching, long enough to be interesting".to_vec();
let hash = OfflineProtocol::data_attachment_hash(&blob);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
let asked = events_named(&alice, "data_attachment_requested");
assert_eq!(
asked.len(),
1,
"the holder's app must be asked exactly once"
);
assert_eq!(asked[0]["hash"].as_str(), Some(hash.as_str()));
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob.clone())
.expect("provide");
settle_media(&mut alice, &mut bob);
let received = events_named(&bob, "data_attachment_received");
assert_eq!(received.len(), 1, "the asking app must be handed the bytes");
assert_eq!(received[0]["hash"].as_str(), Some(hash.as_str()));
assert_eq!(
BASE64
.decode(received[0]["data"].as_str().expect("base64"))
.expect("decode"),
blob
);
assert!(
events_named(&bob, "file_received").is_empty(),
"a data-purposed transfer must never surface as a received file"
);
assert!(
events_named(&bob, "file_progress").is_empty(),
"nor as progress on one"
);
}
#[test]
fn bytes_nobody_asked_for_are_dropped() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let blob = b"unsolicited".to_vec();
let hash = OfflineProtocol::data_attachment_hash(&blob);
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob)
.expect("provide");
settle_media(&mut alice, &mut bob);
assert!(
events_named(&bob, "data_attachment_received").is_empty(),
"bytes must be admitted only against a fetch this device made"
);
assert!(events_named(&bob, "file_received").is_empty());
}
#[test]
fn bytes_that_do_not_match_the_hash_are_refused() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let wanted = OfflineProtocol::data_attachment_hash(b"what was asked for");
bob.protocol
.data_fetch_attachment(&bob_space, &wanted)
.expect("fetch");
pump(&mut bob, &mut alice);
clear_events(&bob);
let err = alice
.protocol
.data_provide_attachment(
&Node::space_for(&bob),
&bob.address,
&wanted,
b"different bytes entirely".to_vec(),
)
.expect_err("the sender's own check must refuse this first");
assert!(
format!("{err}").contains("hash to"),
"unexpected refusal: {err}"
);
alice
.protocol
.send_media_inner(
bob.address.clone(),
b"different bytes entirely, sent anyway".to_vec(),
wanted.clone(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Attachment {
hash: wanted.clone(),
}),
)
.expect("a lying peer sends whatever it likes");
settle_media(&mut alice, &mut bob);
assert!(
events_named(&bob, "data_attachment_received").is_empty(),
"bytes that do not hash to what was asked for must never be handed \
to an application: the address is checked against the bytes, not \
against what the sender says about them"
);
let unavailable = events_named(&bob, "data_attachment_unavailable");
assert_eq!(unavailable.len(), 1, "and the fetch must be told it failed");
assert_eq!(unavailable[0]["reason"].as_str(), Some("hash_mismatch"));
assert!(events_named(&bob, "file_received").is_empty());
}
#[test]
fn a_declined_fetch_ends_rather_than_hanging() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let hash = OfflineProtocol::data_attachment_hash(b"long gone");
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
alice
.protocol
.data_decline_attachment(&Node::space_for(&bob), &bob.address, &hash)
.expect("decline");
pump(&mut alice, &mut bob);
let unavailable = events_named(&bob, "data_attachment_unavailable");
assert_eq!(unavailable.len(), 1);
assert_eq!(unavailable[0]["reason"].as_str(), Some("declined"));
assert_eq!(unavailable[0]["hash"].as_str(), Some(hash.as_str()));
}
#[test]
fn a_refusal_for_a_question_we_never_asked_is_ignored() {
let (mut alice, mut bob) = pair();
let hash = OfflineProtocol::data_attachment_hash(b"never wanted");
alice
.protocol
.data_decline_attachment(&Node::space_for(&bob), &bob.address, &hash)
.expect("decline");
pump(&mut alice, &mut bob);
assert!(
events_named(&bob, "data_attachment_unavailable").is_empty(),
"a peer must not be able to report on fetches this device never made"
);
}
#[test]
fn a_fetch_needs_the_carriage_capability() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let hash = OfflineProtocol::data_attachment_hash(b"anything");
bob.protocol.peer_data_media.remove(&alice.address);
let err = bob
.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect_err("a fetch toward a peer that cannot carry must refuse");
assert!(
format!("{err}").contains("attachment carriage"),
"unexpected error: {err}"
);
assert_eq!(pump(&mut bob, &mut alice), 0, "and no frame may leave");
}
#[test]
fn a_malformed_hash_never_reaches_the_wire() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
for bad in ["", "abcdef", &"z".repeat(64)] {
assert!(
bob.protocol.data_fetch_attachment(&bob_space, bad).is_err(),
"{bad:?} must be refused"
);
}
assert_eq!(pump(&mut bob, &mut alice), 0);
}
#[test]
fn a_document_too_large_to_frame_crosses_over_the_media_path() {
let (mut alice, mut bob) = pair();
let space = Node::space_for(&bob);
fill_past_the_frame_budget(&mut alice, &space, 0x2545_F491_4F6C_DD1D);
settle_media(&mut alice, &mut bob);
let bob_space = Node::space_for(&alice);
assert_eq!(
bob.protocol
.data_map_get(&bob_space, "notes", "m", "k0")
.expect("get")
.is_some(),
true,
"the document must have crossed"
);
assert!(
events_named(&alice, "data_doc_unsyncable").is_empty(),
"and nothing may be reported as a dead end"
);
assert!(events_named(&bob, "file_received").is_empty());
assert!(events_named(&bob, "file_progress").is_empty());
}
#[test]
fn a_peer_that_cannot_carry_snapshots_is_told_rather_than_left_waiting() {
let (mut alice, mut bob) = pair();
let space = Node::space_for(&bob);
alice.protocol.peer_data_media.remove(&bob.address);
fill_past_the_frame_budget(&mut alice, &space, 0x9E37_79B9_7F4A_7C15);
settle_media(&mut alice, &mut bob);
let reported = events_named(&alice, "data_doc_unsyncable");
assert!(
!reported.is_empty(),
"a dead end must be reported, not logged and forgotten"
);
assert_eq!(
reported[0]["reason"].as_str(),
Some("peer_cannot_carry_snapshots")
);
}
#[test]
fn the_send_path_refuses_a_data_purpose_toward_a_peer_that_cannot_route_it() {
let (mut alice, bob) = pair();
alice.protocol.peer_data_media.remove(&bob.address);
let err = alice
.protocol
.send_media_inner(
bob.address.clone(),
b"bytes".to_vec(),
"name".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "notes".to_string(),
}),
)
.expect_err("a data-purposed send must refuse this peer");
assert!(
format!("{err}").contains("attachment carriage"),
"unexpected error: {err}"
);
alice
.protocol
.send_media_inner(
bob.address.clone(),
b"bytes".to_vec(),
"name".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
None,
)
.expect("an ordinary file is unaffected");
}
#[test]
fn a_public_media_send_can_never_carry_a_data_purpose() {
let (mut alice, mut bob) = pair();
alice
.protocol
.send_media_with(
bob.address.clone(),
b"an ordinary file".to_vec(),
"notes.txt".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
)
.expect("send");
settle_media(&mut alice, &mut bob);
assert_eq!(
events_named(&bob, "file_received").len(),
1,
"a file sent through the public surface must arrive as a file"
);
assert!(events_named(&bob, "data_attachment_received").is_empty());
}
#[test]
fn a_chunk_arriving_before_chunk_zero_reports_no_phantom_progress() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
let blob = vec![7u8; 12 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&blob);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob.clone())
.expect("provide");
let mut first = alice.transport.sent_messages();
alice.transport.clear_sent_messages();
assert!(
first.len() > 1,
"the fixture needs a multi-chunk transfer to reorder"
);
first.reverse();
for message in first {
bob.transport.queue_message(message);
}
while bob.protocol.receive_message().is_some() {}
assert!(
events_named(&bob, "file_progress").is_empty(),
"a chunk that arrived before the purpose did must not be reported as \
progress on a file"
);
settle_media(&mut alice, &mut bob);
assert_eq!(
events_named(&bob, "data_attachment_received").len(),
1,
"and the transfer still completes"
);
assert!(events_named(&bob, "file_progress").is_empty());
assert!(events_named(&bob, "file_received").is_empty());
}
#[test]
fn a_data_purposed_transfer_is_invisible_on_the_sending_side_too() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
let blob = b"bytes the document layer moves on its own behalf".to_vec();
let hash = OfflineProtocol::data_attachment_hash(&blob);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
clear_events(&alice);
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob.clone())
.expect("provide");
settle_media(&mut alice, &mut bob);
assert!(
events_named(&alice, "file_progress").is_empty(),
"the serving side must not report progress on an upload nobody made"
);
assert!(
events_named(&alice, "media_sent").is_empty(),
"nor completion of one"
);
assert_eq!(events_named(&bob, "data_attachment_received").len(), 1);
clear_events(&alice);
alice
.protocol
.send_media_with(
bob.address.clone(),
b"an ordinary file".to_vec(),
"notes.txt".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
)
.expect("send");
settle_media(&mut alice, &mut bob);
assert!(!events_named(&alice, "file_progress").is_empty());
assert_eq!(events_named(&alice, "media_sent").len(), 1);
}
#[test]
fn an_interrupted_document_transfer_is_never_handed_back_to_the_app() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
let blob = vec![3u8; 40 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&blob);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob)
.expect("provide");
alice.transport.clear_sent_messages();
alice.restart(&bob.address);
assert!(
events_named(&alice, "media_resend_required").is_empty(),
"an app cannot answer this without stripping the purpose, so it must \
not be asked"
);
}
#[test]
fn an_interrupted_ordinary_transfer_is_still_handed_back() {
let (mut alice, bob) = pair();
alice
.protocol
.send_media_with(
bob.address.clone(),
vec![4u8; 40 * 1024],
"holiday.jpg".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
)
.expect("send");
alice.transport.clear_sent_messages();
alice.restart(&bob.address);
assert_eq!(
events_named(&alice, "media_resend_required").len(),
1,
"an ordinary interrupted transfer must still ask the app to re-supply"
);
}
#[test]
fn a_peer_asking_for_endless_distinct_blobs_is_budgeted() {
let (mut alice, bob) = pair();
let space = Node::space_for(&bob);
let asks = crate::protocol::data_sync::MAX_BLOB_REQUESTS_PER_WINDOW + 40;
for round in 0..asks {
let hash = OfflineProtocol::data_attachment_hash(format!("blob-{round}").as_bytes());
alice.protocol.handle_data_sync_frame(
&bob.address,
&format!(r#"{{"v":1,"k":"need_blob","hash":"{hash}"}}"#),
);
}
let _ = space;
let requested = events_named(&alice, "data_attachment_requested").len();
assert!(
requested <= crate::protocol::data_sync::MAX_BLOB_REQUESTS_PER_WINDOW,
"the application saw {requested} requests; the budget is {}",
crate::protocol::data_sync::MAX_BLOB_REQUESTS_PER_WINDOW
);
assert!(
alice.protocol.blob_request_windows.len()
<= crate::protocol::data_sync::MAX_BLOB_REQUESTS_PER_WINDOW,
"and one peer's share of the map that remembers them is its budget"
);
assert!(
requested > 0,
"a legitimate request must still get through, or this bounds nothing"
);
}
#[test]
fn declining_toward_a_peer_that_cannot_read_it_is_refused() {
let (mut alice, mut bob) = pair();
let space = Node::space_for(&bob);
let hash = OfflineProtocol::data_attachment_hash(b"gone");
alice.protocol.peer_data_media.remove(&bob.address);
let err = alice
.protocol
.data_decline_attachment(&space, &bob.address, &hash)
.expect_err("a decline toward an incapable peer must refuse");
assert!(
format!("{err}").contains("attachment carriage"),
"unexpected error: {err}"
);
assert_eq!(pump(&mut alice, &mut bob), 0, "and no frame may leave");
}
#[test]
fn a_fetch_nobody_answers_eventually_reports() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let hash = OfflineProtocol::data_attachment_hash(b"never answered");
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
clear_events(&bob);
let stale = std::time::Instant::now()
- crate::protocol::data_sync::ATTACHMENT_FETCH_TIMEOUT
- std::time::Duration::from_secs(1);
for pending in bob.protocol.pending_attachment_fetches.values_mut() {
pending.last_seen = stale;
}
bob.protocol.expire_attachment_fetches();
let unavailable = events_named(&bob, "data_attachment_unavailable");
assert_eq!(unavailable.len(), 1, "expiry must report, not just recycle");
assert_eq!(unavailable[0]["reason"].as_str(), Some("timeout"));
assert!(
bob.protocol.pending_attachment_fetches.is_empty(),
"and release the slot"
);
}
#[test]
fn a_chunk_on_a_one_to_one_session_is_dropped() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let alice_space = Node::space_for(&bob);
let bytes = vec![4u8; 2048];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
clear_events(&bob);
alice.protocol.send_chunk_for_test(
&alice_space,
&crate::protocol::data_sync::SyncChannel::Peer,
&hash,
0,
1,
&base64::engine::general_purpose::STANDARD.encode(&bytes),
);
pump(&mut alice, &mut bob);
assert!(
events_named(&bob, "data_attachment_received").is_empty(),
"a chunk was assembled on a session where bytes ride the media path"
);
assert!(
!bob.protocol.pending_attachment_fetches.is_empty(),
"the fetch was ended by a frame this road does not carry"
);
}
#[test]
fn unsolicited_blob_bytes_are_refused_at_the_door() {
let (mut alice, mut bob) = pair();
let blob = vec![9u8; 40 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&blob);
let file_id = alice
.protocol
.send_media_inner(
bob.address.clone(),
blob,
hash.clone(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Attachment { hash }),
)
.expect("a peer sends whatever it likes");
pump(&mut alice, &mut bob);
assert!(
bob.protocol
.file_transfer_manager
.get_progress(&file_id)
.is_none(),
"no assembly may be opened for bytes nobody asked for: refusing at \
the end still spends the storage and the battery this rule protects"
);
settle_media(&mut alice, &mut bob);
assert!(
events_named(&bob, "data_attachment_received").is_empty(),
"and unsolicited bytes must never reach the application"
);
assert!(events_named(&bob, "file_received").is_empty());
}
fn fill_past_the_frame_budget(node: &mut Node, space: &str, mut seed: u64) {
for round in 0..24 {
let filler: String = (0..4096)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
char::from(b'0' + (seed % 64) as u8)
})
.collect();
node.protocol
.data_map_set(
space,
"notes",
"m",
&format!("k{round}"),
DataValue::text(filler),
)
.expect("set");
}
node.protocol.data_flush(space, "notes").expect("flush");
assert!(
node.protocol.data_doc_size(space, "notes").expect("size")
> crate::protocol::data_sync::MAX_SYNC_BLOB_BYTES as u64,
"the fixture must build a document no frame can carry"
);
}
#[test]
fn the_kill_switch_refuses_a_carried_transfer_before_it_is_buffered() {
let (mut alice, mut bob) = pair();
bob.protocol.config.data.enabled = false;
let payload = vec![7u8; 2 * 1024 * 1024];
alice
.protocol
.send_media_inner(
bob.address.clone(),
payload,
"notes".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "notes".to_string(),
}),
)
.expect("a peer that advertised carriage accepts the send");
pump(&mut alice, &mut bob);
assert_eq!(
bob.protocol.file_transfer_manager.active_transfer_count(),
0,
"a device with the layer off must buffer nothing for it"
);
assert!(events_named(&bob, "file_received").is_empty());
assert!(events_named(&bob, "file_progress").is_empty());
let (mut alice, mut bob) = pair();
let payload = vec![7u8; 2 * 1024 * 1024];
alice
.protocol
.send_media_inner(
bob.address.clone(),
payload,
"notes".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "notes".to_string(),
}),
)
.expect("send");
pump(&mut alice, &mut bob);
assert_eq!(
bob.protocol.file_transfer_manager.active_transfer_count(),
1,
"the same transfer must be admitted when the layer is on"
);
}
#[test]
fn a_wipe_takes_the_outstanding_questions_with_it() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
let blob = b"bytes the wipe should make irrelevant".to_vec();
let hash = OfflineProtocol::data_attachment_hash(&blob);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
assert!(
bob.protocol.awaiting_attachment(&bob_space, &hash),
"the fixture must leave a real question outstanding"
);
bob.protocol.data_wipe_all().expect("wipe");
assert!(
!bob.protocol.awaiting_attachment(&bob_space, &hash),
"the wipe must take the question with the content it was about"
);
clear_events(&bob);
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob)
.expect("provide");
settle_media(&mut alice, &mut bob);
assert!(
events_named(&bob, "data_attachment_received").is_empty(),
"an answer to a wiped question must not reach the application"
);
assert!(events_named(&bob, "file_received").is_empty());
}
#[test]
fn a_fetch_evicted_by_a_newer_one_is_reported_rather_than_forgotten() {
let (mut bob, alice) = pair();
let space = Node::space_for(&alice);
let cap = crate::protocol::data_sync::MAX_PENDING_ATTACHMENT_FETCHES;
let hash_of = |n: usize| OfflineProtocol::data_attachment_hash(format!("blob {n}").as_bytes());
for n in 0..cap {
bob.protocol
.data_fetch_attachment(&space, &hash_of(n))
.expect("fetch");
}
clear_events(&bob);
assert!(
bob.protocol.awaiting_attachment(&space, &hash_of(0)),
"the oldest fetch must still be outstanding before the one that evicts it"
);
bob.protocol
.data_fetch_attachment(&space, &hash_of(cap))
.expect("fetch");
let ended = events_named(&bob, "data_attachment_unavailable");
assert_eq!(
ended.len(),
1,
"exactly the evicted fetch is reported: {ended:?}"
);
assert_eq!(ended[0]["reason"].as_str(), Some("evicted"));
assert_eq!(ended[0]["hash"].as_str(), Some(hash_of(0).as_str()));
assert!(
!bob.protocol.awaiting_attachment(&space, &hash_of(0)),
"and the entry it named is really gone"
);
}
#[test]
fn a_request_this_device_could_not_answer_is_never_put_to_the_application() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let hash = OfflineProtocol::data_attachment_hash(b"something asked for");
alice.protocol.peer_data_media.remove(&bob.address);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
assert!(
events_named(&alice, "data_attachment_requested").is_empty(),
"a request that cannot be answered must not be raised"
);
alice.protocol.peer_data_media.insert(bob.address.clone());
let other = OfflineProtocol::data_attachment_hash(b"asked for later");
bob.protocol
.data_fetch_attachment(&bob_space, &other)
.expect("fetch");
pump(&mut bob, &mut alice);
let asked = events_named(&alice, "data_attachment_requested");
assert_eq!(asked.len(), 1, "the answerable request must be raised");
assert_eq!(asked[0]["hash"].as_str(), Some(other.as_str()));
}
#[test]
fn a_transfer_that_failed_before_its_purpose_arrived_reports_no_file_failure() {
let (mut alice, mut bob) = pair();
bob.protocol.file_transfer_manager = crate::file_transfer::FileTransferManager::with_config(
crate::file_transfer::FileTransferConfig {
max_concurrent_assemblies: 1,
..Default::default()
},
);
fn chunks_of(
alice: &mut Node,
bob: &Node,
name: &str,
fill: u8,
) -> Vec<offline_protocol_core::Message> {
let file_id = alice
.protocol
.send_media_with(
bob.address.clone(),
vec![fill; 12 * 1024],
name.to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
)
.expect("send");
let chunks = alice.transport.sent_messages();
alice.transport.clear_sent_messages();
alice
.protocol
.abort_outbound_media_transfer(&file_id, "test is done sending");
assert!(
chunks.len() > 1,
"the fixture needs a multi-chunk transfer for {name}"
);
chunks
}
let holding = chunks_of(&mut alice, &bob, "holding.bin", 1);
bob.transport.queue_message(holding[0].clone());
while bob.protocol.receive_message().is_some() {}
assert_eq!(
bob.protocol.file_transfer_manager.active_transfer_count(),
1,
"the slot must really be occupied, or nothing below is refused"
);
let unidentified = chunks_of(&mut alice, &bob, "unidentified.bin", 2);
clear_events(&bob);
bob.transport.queue_message(unidentified[1].clone());
while bob.protocol.receive_message().is_some() {}
assert!(
events_named(&bob, "file_receive_failed").is_empty(),
"a transfer whose purpose has not arrived must not be failed as a \
file: it may be the document layer's, and no progress was ever \
reported for it either"
);
let identified = chunks_of(&mut alice, &bob, "identified.bin", 3);
clear_events(&bob);
bob.transport.queue_message(identified[0].clone());
while bob.protocol.receive_message().is_some() {}
let failed = events_named(&bob, "file_receive_failed");
assert_eq!(
failed.len(),
1,
"an ordinary transfer refused after its chunk 0 arrived must still \
report: {failed:?}"
);
assert_eq!(failed[0]["file_name"].as_str(), Some("identified.bin"));
}
#[test]
fn a_fetch_does_not_expire_while_its_own_answer_is_arriving() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
let blob = vec![3u8; 2 * 1024 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&blob);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob.clone())
.expect("provide");
let stale = std::time::Instant::now()
- crate::protocol::data_sync::ATTACHMENT_FETCH_TIMEOUT
- std::time::Duration::from_secs(1);
for pending in bob.protocol.pending_attachment_fetches.values_mut() {
pending.last_seen = stale;
}
alice.protocol.pump_media_transfers();
pump(&mut alice, &mut bob);
assert!(
bob.protocol.file_transfer_manager.active_transfer_count() > 0,
"the fixture must reach the sweep with a transfer actually in flight"
);
bob.protocol.expire_attachment_fetches();
assert!(
events_named(&bob, "data_attachment_unavailable").is_empty(),
"a fetch whose bytes are arriving must not be reported as unanswered"
);
settle_media(&mut alice, &mut bob);
let received = events_named(&bob, "data_attachment_received");
assert_eq!(
received.len(),
1,
"the blob crossed in full and must be handed over"
);
assert_eq!(
BASE64
.decode(received[0]["data"].as_str().expect("base64"))
.expect("decode"),
blob
);
}
#[test]
fn a_document_already_crossing_is_not_sent_a_second_time() {
let (mut alice, mut bob) = pair();
let space = Node::space_for(&bob);
fill_past_the_frame_budget(&mut alice, &space, 0x1234_5678_9ABC_DEF0);
pump(&mut alice, &mut bob);
pump(&mut bob, &mut alice);
let crossing = |node: &Node| {
node.protocol
.outbound_media_transfers
.values()
.filter(|transfer| transfer.data_purpose.is_some())
.count()
};
assert_eq!(crossing(&alice), 1, "the document must be on its way");
for _ in 0..3 {
alice
.protocol
.nudge_data_sync(&space, None, "test_repeat_exchange");
pump(&mut alice, &mut bob);
pump(&mut bob, &mut alice);
}
assert_eq!(
crossing(&alice),
1,
"a repeated exchange must not start a second copy of one document"
);
alice
.protocol
.send_media_with(
bob.address.clone(),
vec![1u8; 4096],
"holiday.jpg",
offline_protocol_core::ContentType::Image,
crate::protocol::types::MediaSendOptions::default(),
)
.expect("the app's own media send must still have a slot");
settle_media(&mut alice, &mut bob);
assert_eq!(
crossing(&alice),
0,
"a finished crossing must release what it held"
);
let bob_space = Node::space_for(&alice);
assert!(
bob.protocol
.data_map_get(&bob_space, "notes", "m", "k0")
.expect("get")
.is_some(),
"and the document must have arrived"
);
fill_past_the_frame_budget(&mut alice, &space, 0x0FED_CBA9_8765_4321);
pump(&mut alice, &mut bob);
pump(&mut bob, &mut alice);
assert_eq!(
crossing(&alice),
1,
"a document that grew again must be able to cross again"
);
}
#[test]
fn a_snapshot_larger_than_a_record_is_refused_before_it_is_buffered() {
let (mut alice, mut bob) = pair();
let over = crate::protocol::data_sync::MAX_MEDIA_SNAPSHOT_BYTES + 64 * 1024;
alice
.protocol
.send_media_inner(
bob.address.clone(),
vec![5u8; over],
"notes".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "notes".to_string(),
}),
)
.expect("a peer that advertised carriage accepts the send");
pump(&mut alice, &mut bob);
assert_eq!(
bob.protocol.file_transfer_manager.active_transfer_count(),
0,
"a snapshot past the record ceiling must buffer nothing"
);
assert!(events_named(&bob, "file_received").is_empty());
assert!(events_named(&bob, "file_progress").is_empty());
let (mut alice, mut bob) = pair();
alice
.protocol
.send_media_inner(
bob.address.clone(),
vec![5u8; 2 * 1024 * 1024],
"notes".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "notes".to_string(),
}),
)
.expect("send");
pump(&mut alice, &mut bob);
assert_eq!(
bob.protocol.file_transfer_manager.active_transfer_count(),
1,
"the same road must admit a snapshot the ceiling allows"
);
}
#[test]
fn a_duplicate_chunk_zero_cannot_reclassify_a_transfer() {
let (mut alice, mut bob) = pair();
let file_id = "reclassify-me".to_string();
let blob = vec![0xA5u8; 64 * 1024];
let options = || crate::protocol::types::MediaSendOptions {
file_id: Some(file_id.clone()),
..Default::default()
};
alice
.protocol
.send_media_inner(
bob.address.clone(),
blob.clone(),
"snapshot",
offline_protocol_core::ContentType::File,
options(),
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "notes".to_string(),
}),
)
.expect("carry the snapshot");
alice.protocol.pump_media_transfers();
pump(&mut alice, &mut bob);
assert!(
bob.protocol
.pending_media_metadata
.get(&file_id)
.is_some_and(|entry| entry.data_purpose.is_some()),
"chunk 0 must have opened a transfer this device knows is internal"
);
assert!(bob.protocol.file_transfer_manager.active_transfer_count() > 0);
clear_events(&bob);
alice.protocol.outbound_media_transfers.remove(&file_id);
alice
.protocol
.send_media_inner(
bob.address.clone(),
blob,
"snapshot",
offline_protocol_core::ContentType::File,
options(),
None,
)
.expect("the lie is sendable; it is the receiver that must refuse it");
alice.protocol.pump_media_transfers();
pump(&mut alice, &mut bob);
assert_eq!(
bob.protocol.file_transfer_manager.active_transfer_count(),
0,
"a chunk 0 that reclassifies a transfer must end it, not rewrite it"
);
assert!(
bob.protocol.pending_media_metadata.get(&file_id).is_none(),
"and leave no state behind"
);
settle_media(&mut alice, &mut bob);
assert!(
events_named(&bob, "file_received").is_empty(),
"document-layer bytes must never surface as a downloaded file"
);
assert!(
events_named(&bob, "file_progress").is_empty(),
"nor as progress on one"
);
}
#[test]
fn every_attachment_surface_answers_data_disabled() {
let (mut alice, bob) = pair();
let space = Node::space_for(&bob);
let hash = OfflineProtocol::data_attachment_hash(b"anything");
alice.protocol.config.data.enabled = false;
let disabled = |result: crate::Result<()>, surface: &str| match result {
Err(crate::Error::DataDisabled) => {}
other => panic!("{surface} must answer DataDisabled, got {other:?}"),
};
disabled(
alice.protocol.data_fetch_attachment(&space, &hash),
"fetch_attachment",
);
disabled(
alice
.protocol
.data_provide_attachment(&space, &bob.address, &hash, b"anything".to_vec()),
"provide_attachment",
);
disabled(
alice
.protocol
.data_decline_attachment(&space, &bob.address, &hash),
"decline_attachment",
);
}
#[test]
fn forgetting_a_peer_reports_the_fetches_it_ends() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let hash = OfflineProtocol::data_attachment_hash(b"bytes alice holds");
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
assert_eq!(
bob.protocol.pending_attachment_fetches.len(),
1,
"the fixture must reach a fetch that is actually outstanding"
);
clear_events(&bob);
bob.protocol.forget_data_sync_peer(&alice.address);
let unavailable = events_named(&bob, "data_attachment_unavailable");
assert_eq!(
unavailable.len(),
1,
"forgetting a peer must report the fetch it ends"
);
assert_eq!(unavailable[0]["reason"].as_str(), Some("peer_gone"));
assert_eq!(unavailable[0]["hash"].as_str(), Some(hash.as_str()));
assert!(
bob.protocol.pending_attachment_fetches.is_empty(),
"and release the slot"
);
}
#[test]
fn evicting_every_peer_reports_the_fetches_it_ends() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let hash = OfflineProtocol::data_attachment_hash(b"bytes alice holds");
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
assert_eq!(
bob.protocol.pending_attachment_fetches.len(),
1,
"the fixture must reach a fetch that is actually outstanding"
);
clear_events(&bob);
bob.protocol.forget_every_data_sync_peer();
let unavailable = events_named(&bob, "data_attachment_unavailable");
assert_eq!(
unavailable.len(),
1,
"forgetting every peer must report the fetch it ends"
);
assert_eq!(unavailable[0]["reason"].as_str(), Some("peer_gone"));
assert_eq!(unavailable[0]["hash"].as_str(), Some(hash.as_str()));
assert_eq!(
unavailable[0]["space_id"].as_str(),
Some(bob_space.as_str())
);
assert!(
bob.protocol.pending_attachment_fetches.is_empty(),
"and release the slot"
);
}
#[test]
fn a_document_layer_transfer_is_never_sent_unsealed() {
let (mut alice, bob) = pair();
let space = Node::space_for(&bob);
let blob = b"bytes that must not leave unmarked".to_vec();
let hash = OfflineProtocol::data_attachment_hash(&blob);
alice.protocol.config.encryption.enabled = false;
alice.protocol.config.encryption.require_encryption = false;
let err = alice
.protocol
.data_provide_attachment(&space, &bob.address, &hash, blob.clone())
.expect_err("a purposed transfer that cannot be sealed must be refused");
assert!(
matches!(err, crate::Error::EncryptFailed(_)),
"unexpected refusal: {err:?}"
);
alice
.protocol
.send_media_with(
bob.address.clone(),
blob,
"holiday.jpg",
offline_protocol_core::ContentType::Image,
crate::protocol::types::MediaSendOptions::default(),
)
.expect("the opt-out still sends ordinary media");
}
#[test]
fn internal_transfers_leave_the_application_a_slot() {
let (mut alice, mut bob) = pair();
let space = Node::space_for(&bob);
fill_past_the_frame_budget(&mut alice, &space, 0x0BAD_F00D_0BAD_F00D);
pump(&mut alice, &mut bob);
pump(&mut bob, &mut alice);
let internal = |node: &Node| {
node.protocol
.outbound_media_transfers
.values()
.filter(|transfer| transfer.data_purpose.is_some())
.count()
};
assert_eq!(internal(&alice), 1, "the document must be on its way");
let blob = vec![7u8; 32 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&blob);
let err = alice
.protocol
.data_provide_attachment(&space, &bob.address, &hash, blob)
.expect_err("a second internal transfer must not take the app's slot");
assert!(
matches!(err, crate::Error::MediaTransferLimit(_)),
"unexpected refusal: {err:?}"
);
assert_eq!(internal(&alice), 1);
alice
.protocol
.send_media_with(
bob.address.clone(),
vec![1u8; 4096],
"holiday.jpg",
offline_protocol_core::ContentType::Image,
crate::protocol::types::MediaSendOptions::default(),
)
.expect("the app's own media send must still have a slot");
}
#[test]
fn a_fetch_that_has_already_ended_is_not_reported_twice() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let alice_space = Node::space_for(&bob);
let blob = vec![3u8; 64 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&blob);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
clear_events(&bob);
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob)
.expect("provide");
alice.protocol.pump_media_transfers();
pump(&mut alice, &mut bob);
assert!(
bob.protocol.file_transfer_manager.active_transfer_count() > 0,
"the fixture must reach a decline that races bytes still in flight"
);
alice
.protocol
.data_decline_attachment(&alice_space, &bob.address, &hash)
.expect("decline");
pump(&mut alice, &mut bob);
let after_decline = events_named(&bob, "data_attachment_unavailable");
assert_eq!(
after_decline.len(),
1,
"the decline is the one answer this fetch gets"
);
assert_eq!(after_decline[0]["reason"].as_str(), Some("declined"));
bob.protocol.report_data_media_transfer_failure(
&alice.address,
&crate::media_envelope::DataPurpose::Attachment { hash: hash.clone() },
"stale_timeout",
);
assert_eq!(
events_named(&bob, "data_attachment_unavailable").len(),
1,
"a fetch that has already ended must not be reported a second time"
);
}
#[test]
fn the_requester_bounds_how_often_it_asks() {
let (alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let hash = OfflineProtocol::data_attachment_hash(b"asked for repeatedly");
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
let first = bob.transport.sent_messages().len();
assert_eq!(first, 1, "the first ask must reach the wire");
for _ in 0..5 {
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("a repeat inside the window is not an error");
}
assert_eq!(
bob.transport.sent_messages().len(),
first,
"a repeat inside the window must not reach the wire"
);
let stale = std::time::Instant::now()
- crate::protocol::data_sync::DATA_SYNC_OFFER_INTERVAL
- std::time::Duration::from_secs(1);
for pending in bob.protocol.pending_attachment_fetches.values_mut() {
pending.last_seen = stale;
}
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch again");
assert_eq!(
bob.transport.sent_messages().len(),
first + 1,
"a repeat after the window must reach the wire"
);
}
#[test]
fn a_holder_bounds_repeats_of_one_request() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let hash = OfflineProtocol::data_attachment_hash(b"asked for repeatedly");
let mut asked = 0;
for _ in 0..4 {
bob.protocol.pending_attachment_fetches.clear();
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("ask again");
asked += pump(&mut bob, &mut alice);
}
assert_eq!(
asked, 4,
"the fixture must have carried four identical questions to the holder"
);
assert_eq!(
events_named(&alice, "data_attachment_requested").len(),
1,
"one question repeated must reach the application once"
);
}
#[test]
fn the_request_map_is_bounded_across_peers_too() {
let (mut alice, bob) = pair();
let per_peer = crate::protocol::data_sync::MAX_BLOB_REQUESTS_PER_WINDOW;
let cap = crate::protocol::data_sync::MAX_BLOB_REQUEST_WINDOWS;
let peers = cap / per_peer + 2;
for peer in 0..peers {
let address = format!("{}{peer:04}", bob.address);
alice.protocol.peer_data_sync.insert(address.clone());
alice.protocol.peer_data_media.insert(address.clone());
for round in 0..per_peer {
let hash = OfflineProtocol::data_attachment_hash(format!("{peer}-{round}").as_bytes());
alice.protocol.handle_data_sync_frame(
&address,
&format!(r#"{{"v":1,"k":"need_blob","hash":"{hash}"}}"#),
);
}
}
assert!(
peers * per_peer > cap,
"the fixture offered {} entries against a cap of {cap}",
peers * per_peer
);
assert!(
alice.protocol.blob_request_windows.len() <= cap,
"the map grew to {} against a cap of {cap}",
alice.protocol.blob_request_windows.len()
);
assert!(
alice.protocol.blob_request_windows.len() > per_peer,
"the map holds {} entries, no more than one peer's budget of {per_peer}: \
the frames are being refused before they reach the window",
alice.protocol.blob_request_windows.len()
);
}
#[test]
fn a_stale_document_layer_transfer_reports_to_the_fetch_not_the_app() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
let alice_space = Node::space_for(&bob);
let blob = vec![9u8; 64 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&blob);
bob.protocol
.data_fetch_attachment(&bob_space, &hash)
.expect("fetch");
pump(&mut bob, &mut alice);
alice
.protocol
.data_provide_attachment(&alice_space, &bob.address, &hash, blob)
.expect("provide");
alice.protocol.pump_media_transfers();
pump(&mut alice, &mut bob);
assert!(
bob.protocol.file_transfer_manager.active_transfer_count() > 0,
"the fixture must reach a transfer that is open when it goes stale"
);
clear_events(&bob);
bob.protocol
.file_transfer_manager
.backdate_transfers(std::time::Duration::from_secs(
crate::protocol::MEDIA_TRANSFER_STALE_TIMEOUT_SECS + 60,
));
bob.protocol.cleanup_expired_entries();
assert!(
events_named(&bob, "file_receive_failed").is_empty(),
"a document-layer transfer must never be named as a failed download"
);
let unavailable = events_named(&bob, "data_attachment_unavailable");
assert_eq!(
unavailable.len(),
1,
"the fetch waiting on those bytes must be told to stop waiting"
);
assert_eq!(unavailable[0]["reason"].as_str(), Some("stale_timeout"));
}
#[test]
fn aborting_a_document_layer_transfer_does_not_fail_a_file_nobody_sent() {
let (mut alice, mut bob) = pair();
let space = Node::space_for(&bob);
fill_past_the_frame_budget(&mut alice, &space, 0x5EED_1234_5EED_1234);
pump(&mut alice, &mut bob);
pump(&mut bob, &mut alice);
let file_id = alice
.protocol
.outbound_media_transfers
.iter()
.find(|(_, transfer)| transfer.data_purpose.is_some())
.map(|(file_id, _)| file_id.clone())
.expect("the document must be on its way");
clear_events(&alice);
alice
.protocol
.abort_outbound_media_transfer(&file_id, "transport gone");
assert!(
events_named(&alice, "media_send_failed").is_empty(),
"an internal transfer has no application-facing identity to fail"
);
let app_file_id = alice
.protocol
.send_media_with(
bob.address.clone(),
vec![1u8; 4096],
"holiday.jpg",
offline_protocol_core::ContentType::Image,
crate::protocol::types::MediaSendOptions::default(),
)
.expect("send");
alice
.protocol
.abort_outbound_media_transfer(&app_file_id, "transport gone");
assert_eq!(
events_named(&alice, "media_send_failed").len(),
1,
"an ordinary transfer aborted the same way must still report"
);
}
fn holds(node: &mut Node, space: &str, doc: &str) -> bool {
node.protocol
.data_list_docs(space)
.expect("list")
.iter()
.any(|name| name == doc)
}
fn removal_records(node: &Node) -> Vec<String> {
node.state
.list_keys(storage_keys::DATA_DOC_META)
.unwrap_or_default()
}
#[test]
fn a_removal_reaches_the_other_replica_and_is_not_offered_back() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
assert!(
holds(&mut bob, &bob_space, "notes"),
"the fixture never replicated"
);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
let rounds = settle(&mut alice, &mut bob);
assert!(!holds(&mut alice, &alice_space, "notes"));
assert!(
!holds(&mut bob, &bob_space, "notes"),
"the removal did not reach the peer"
);
assert_eq!(
rounds.last(),
Some(&0),
"the exchange did not terminate: {rounds:?}"
);
alice.protocol.kick_data_sync(&alice_space, "test");
bob.protocol.kick_data_sync(&bob_space, "test");
settle(&mut alice, &mut bob);
assert!(!holds(&mut alice, &alice_space, "notes"));
assert!(!holds(&mut bob, &bob_space, "notes"));
assert!(matches!(
bob.protocol.data_map_get(&bob_space, "notes", "m", "title"),
Err(crate::Error::InvalidState(_))
));
}
#[test]
fn an_edit_made_while_a_removal_was_in_flight_brings_the_document_back() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
write(&mut bob, &bob_space, "notes", "added", "B");
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
alice.protocol.kick_data_sync(&alice_space, "test");
bob.protocol.kick_data_sync(&bob_space, "test");
let rounds = settle(&mut alice, &mut bob);
assert!(
holds(&mut bob, &bob_space, "notes"),
"the editing replica lost its own concurrent edit"
);
assert!(
holds(&mut alice, &alice_space, "notes"),
"the removing replica never got the concurrent edit back"
);
assert_eq!(
read(&mut alice, &alice_space, "notes", "added"),
Some(DataValue::text("B"))
);
assert_eq!(
read(&mut alice, &alice_space, "notes", "title"),
Some(DataValue::text("hello")),
"the document came back without the content the edit was made on"
);
assert_eq!(
rounds.last(),
Some(&0),
"the exchange did not terminate: {rounds:?}"
);
}
#[test]
fn a_removal_is_reported_to_the_application_on_both_sides() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice.events.lock().unwrap().clear();
bob.events.lock().unwrap().clear();
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
settle(&mut alice, &mut bob);
let reported = |node: &Node| -> Vec<String> {
node.events
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
crate::Event::DataDocRemoved { doc_id, by, .. } => Some(format!("{doc_id}:{by}")),
_ => None,
})
.collect()
};
assert_eq!(reported(&alice), vec!["notes:local".to_string()]);
assert_eq!(
reported(&bob),
vec!["notes:peer".to_string()],
"the peer was never told why the document disappeared"
);
}
#[test]
fn a_blob_carrying_only_removed_content_never_reaches_the_engine() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
bob.protocol.kick_data_sync(&bob_space, "test");
settle(&mut alice, &mut bob);
assert!(
!holds(&mut alice, &alice_space, "notes"),
"a peer's copy of removed content recreated the document"
);
}
#[test]
fn a_name_that_was_removed_can_be_used_again_without_its_old_contents() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "old", "gone");
settle(&mut alice, &mut bob);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
settle(&mut alice, &mut bob);
alice
.protocol
.data_create_doc(&alice_space, "notes")
.expect("create again");
write(&mut alice, &alice_space, "notes", "new", "fresh");
settle(&mut alice, &mut bob);
for (node, space, label) in [
(&mut alice, alice_space.as_str(), "alice"),
(&mut bob, bob_space.as_str(), "bob"),
] {
assert_eq!(
read(node, space, "notes", "new"),
Some(DataValue::text("fresh")),
"{label} did not get the new document"
);
assert_eq!(
read(node, space, "notes", "old"),
None,
"{label} carried the removed contents into the new document"
);
}
}
#[test]
fn a_removal_survives_a_relaunch() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
alice.restart(&bob.address);
assert!(
!holds(&mut alice, &alice_space, "notes"),
"a relaunch read the removed name as live"
);
assert!(matches!(
alice
.protocol
.data_map_get(&alice_space, "notes", "m", "title"),
Err(crate::Error::InvalidState(_))
));
}
#[test]
fn a_removal_the_peer_cannot_read_still_holds_here() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice.protocol.peer_data_tombstones.remove(&bob.address);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
let rounds = settle(&mut alice, &mut bob);
assert!(
holds(&mut bob, &bob_space, "notes"),
"a peer that cannot read removals should have kept its copy"
);
assert!(
!holds(&mut alice, &alice_space, "notes"),
"an old peer's re-offer resurrected a removed document"
);
assert_eq!(
rounds.last(),
Some(&0),
"the exchange with an old peer did not terminate: {rounds:?}"
);
for _ in 0..3 {
bob.protocol.kick_data_sync(&bob_space, "test");
settle(&mut alice, &mut bob);
}
assert!(!holds(&mut alice, &alice_space, "notes"));
}
#[test]
fn evicting_a_document_records_no_removal_and_is_refilled() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice
.protocol
.data_delete_doc(&alice_space, "notes")
.expect("delete");
assert!(
removal_records(&alice).is_empty(),
"eviction wrote a removal record"
);
bob.protocol.kick_data_sync(&bob_space, "test");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut alice, &alice_space, "notes", "title"),
Some(DataValue::text("hello")),
"an evicted document was not refilled by the peer"
);
}
#[test]
fn removing_a_space_removes_every_document_in_it() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
for doc in ["one", "two", "three"] {
write(&mut alice, &alice_space, doc, "k", "v");
}
settle(&mut alice, &mut bob);
assert_eq!(
bob.protocol.data_list_docs(&bob_space).expect("list").len(),
3
);
alice
.protocol
.data_remove_space(&alice_space)
.expect("remove space");
settle(&mut alice, &mut bob);
assert!(alice
.protocol
.data_list_docs(&alice_space)
.expect("list")
.is_empty());
assert!(
bob.protocol
.data_list_docs(&bob_space)
.expect("list")
.is_empty(),
"removing a space left documents on the peer"
);
}
#[test]
fn removing_a_space_purges_the_deltas_its_own_flushes_wrote() {
let mut alice = Node::new("alice");
let space = id("bob");
for doc in ["one", "two", "three"] {
write(&mut alice, &space, doc, "k", "v");
alice
.protocol
.data_map_set(&space, doc, "m", "later", DataValue::text("pending"))
.expect("set");
}
alice
.protocol
.data_remove_space(&space)
.expect("remove space");
for doc in ["one", "two", "three"] {
assert_eq!(
alice.delta_records(&space, doc),
0,
"a delta the removal flushed survived its own purge: {doc}"
);
alice
.protocol
.data_create_doc(&space, doc)
.expect("create again");
assert_eq!(
read(&mut alice, &space, doc, "later"),
None,
"the removed contents came back under a re-used name: {doc}"
);
assert_eq!(read(&mut alice, &space, doc, "k"), None);
}
}
#[test]
fn a_wipe_takes_the_removal_records_with_it() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
assert!(
!removal_records(&alice).is_empty(),
"the fixture never wrote a removal record"
);
alice.protocol.data_wipe_all().expect("wipe");
assert!(
removal_records(&alice).is_empty(),
"a logout left the removal records behind on a custom backend"
);
}
#[test]
fn the_removal_record_reaches_disk_before_the_records_it_removes() {
let (mut alice, writes) = recording_protocol("alice");
let space = id("bob");
alice
.data_map_set(&space, "notes", "m", "title", DataValue::text("hello"))
.expect("set");
alice.data_flush(&space, "notes").expect("flush");
assert!(
alice.data_doc_size(&space, "notes").expect("size") > 0,
"precondition: the document has to exist for its removal to delete anything"
);
writes.lock().unwrap().clear();
alice.data_remove_doc(&space, "notes").expect("remove");
let order: Vec<String> = writes
.lock()
.unwrap()
.iter()
.filter(|entry| {
entry.as_str() == storage_keys::DATA_DOC_META
|| entry.as_str() == format!("del:{}", storage_keys::DATA_DOCS)
|| entry.as_str() == format!("del:{}", storage_keys::DATA_DELTA_LOG)
})
.cloned()
.collect();
assert_eq!(
order.first().map(String::as_str),
Some(storage_keys::DATA_DOC_META),
"the removal record has to be durable before the records it removes: {order:?}"
);
assert!(
order
.iter()
.any(|entry| entry == &format!("del:{}", storage_keys::DATA_DOCS)),
"precondition: the removal has to delete the document record: {order:?}"
);
}
#[test]
fn a_backend_swap_carries_the_removals() {
let mut alice = Node::new("alice");
let space = id("bob");
alice
.protocol
.data_map_set(&space, "notes", "m", "title", DataValue::text("hello"))
.expect("set");
alice.protocol.data_flush(&space, "notes").expect("flush");
alice
.protocol
.data_remove_doc(&space, "notes")
.expect("remove");
let second = Arc::new(InMemoryStorage::new());
alice
.protocol
.set_data_storage(Arc::new(TestProtocolStateStorage {
storage: second.clone(),
}))
.expect("swap");
assert!(
!second
.list_keys(storage_keys::DATA_DOC_META)
.unwrap_or_default()
.is_empty(),
"the new backend has no record that the document was ever removed"
);
assert!(
!alice
.protocol
.data_list_docs(&space)
.expect("list")
.iter()
.any(|name| name == "notes"),
"the swap brought a removed document back"
);
}
#[test]
fn removed_names_count_against_the_document_ceiling() {
let mut alice = Node::new("alice");
let space = id("bob");
for n in 0..4 {
let doc = format!("doc{n}");
alice
.protocol
.data_map_set(&space, &doc, "m", "k", DataValue::text("v"))
.expect("set");
alice.protocol.data_flush(&space, &doc).expect("flush");
alice
.protocol
.data_remove_doc(&space, &doc)
.expect("remove");
}
assert!(
alice
.protocol
.data_list_docs(&space)
.expect("list")
.is_empty(),
"precondition: every document was removed"
);
assert!(
!alice.protocol.data_space_admits_doc(&space, "fresh", 4),
"four removed names did not count toward a ceiling of four"
);
assert!(
alice.protocol.data_space_admits_doc(&space, "fresh", 5),
"the ceiling refused a name it had room for"
);
}
#[test]
fn a_removal_a_crash_interrupted_is_finished_at_the_next_open() {
let mut alice = Node::new("alice");
let space = id("bob");
write(&mut alice, &space, "notes", "title", "hello");
assert!(
alice.delta_records(&space, "notes") > 0,
"precondition: the document has to have records for their survival to mean anything"
);
let storage = alice
.protocol
.data_storage_for_sync()
.expect("the data layer is on");
let floor = alice
.protocol
.data_doc_version(&space, "notes")
.expect("version");
let bytes =
serde_json::to_vec(&serde_json::json!({ "gone": floor, "alive": false })).expect("encode");
alice
.protocol
.write_state_record(
storage.as_ref(),
storage_keys::DATA_DOC_META,
&format!("{space}/notes"),
&bytes,
)
.expect("seed the removal record");
alice.restart(&bob_address());
assert!(
!holds(&mut alice, &space, "notes"),
"surviving records voted a removed document back into existence"
);
assert_eq!(
alice.delta_records(&space, "notes"),
0,
"the interrupted removal was never finished, so its records are still there to \
be replayed into the next document of that name"
);
}
#[test]
fn a_floor_already_held_is_answered_without_a_write_or_a_deletion() {
let (mut alice, writes) = recording_protocol("alice");
let space = id("bob");
alice
.data_map_set(&space, "notes", "m", "title", DataValue::text("hello"))
.expect("set");
alice.data_flush(&space, "notes").expect("flush");
let floor = VersionToken::from_bytes(
BASE64
.decode(alice.data_doc_version(&space, "notes").expect("version"))
.expect("base64"),
);
assert_eq!(
alice
.data_apply_tombstone(&space, "notes", &floor)
.expect("apply"),
TombstoneOutcome::Deleted
);
let meta_writes = |writes: &Arc<Mutex<Vec<String>>>| -> usize {
writes
.lock()
.unwrap()
.iter()
.filter(|entry| entry.as_str() == storage_keys::DATA_DOC_META)
.count()
};
writes.lock().unwrap().clear();
assert_eq!(
alice
.data_apply_tombstone(&space, "notes", &floor)
.expect("apply again"),
TombstoneOutcome::Unchanged
);
assert_eq!(
meta_writes(&writes),
0,
"a floor already held was written again"
);
alice
.data_create_doc(&space, "notes")
.expect("create again");
writes.lock().unwrap().clear();
assert_eq!(
alice
.data_apply_tombstone(&space, "notes", &floor)
.expect("apply once more"),
TombstoneOutcome::Unchanged
);
assert!(
alice
.data_list_docs(&space)
.expect("list")
.contains(&"notes".to_string()),
"a floor that was already judged deleted a document created after it"
);
assert_eq!(
meta_writes(&writes),
0,
"a floor already held was written again for a name that came back"
);
}
#[test]
fn a_removal_for_a_held_document_still_applies_at_the_ceiling() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let mut sent = 0usize;
while sent < MAX_DOCS_PER_SPACE {
let docs: Vec<String> = (sent..sent + 128)
.map(|n| format!(r#""doc{n:04}":"""#))
.collect();
alice.protocol.handle_data_sync_frame(
&bob.address,
&format!(
r#"{{"v":1,"k":"vv","reply":true,"partial":true,"docs":{{{}}}}}"#,
docs.join(",")
),
);
sent += 128;
}
assert_eq!(
alice
.protocol
.data_list_docs(&alice_space)
.expect("list")
.len(),
MAX_DOCS_PER_SPACE,
"precondition: the space has to be full"
);
let empty = alice
.protocol
.data_doc_version(&alice_space, "doc0000")
.expect("version");
alice.protocol.handle_data_sync_frame(
&bob.address,
&format!(
r#"{{"v":1,"k":"vv","reply":true,"partial":true,"docs":{{}},"gone":{{"doc0000":"{empty}"}}}}"#
),
);
assert!(
!holds(&mut alice, &alice_space, "doc0000"),
"precondition: a removal for a held document applies at the ceiling"
);
alice.protocol.handle_data_sync_frame(
&bob.address,
&format!(
r#"{{"v":1,"k":"vv","reply":true,"partial":true,"docs":{{}},"gone":{{"aaaa-fresh":"{empty}","doc0000":"{empty}","doc0001":"{empty}"}}}}"#
),
);
assert!(
!removal_records(&alice).contains(&format!("{alice_space}/aaaa-fresh")),
"the ceiling admitted a fresh name"
);
assert!(
!holds(&mut alice, &alice_space, "doc0001"),
"a removal for a held document was dropped at the ceiling"
);
let bob_space = Node::space_for(&alice);
write(
&mut bob,
&bob_space,
"doc0000",
"title",
"edited past the removal",
);
let blob = bob
.protocol
.data_export_snapshot(&bob_space, "doc0000")
.expect("snapshot");
alice
.protocol
.handle_data_sync_frame(&bob.address, &snapshot_frame("doc0000", &blob));
assert!(
holds(&mut alice, &alice_space, "doc0000"),
"an edit beyond the floor was refused at the ceiling, though the name costs nothing new"
);
assert_eq!(
read(&mut alice, &alice_space, "doc0000", "title"),
Some(DataValue::text("edited past the removal")),
"the edit that brought the name back did not bring its contents"
);
}
#[test]
fn removing_a_space_announces_once() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
for doc in ["one", "two", "three"] {
write(&mut alice, &alice_space, doc, "k", "v");
}
settle(&mut alice, &mut bob);
alice.transport.clear_sent_messages();
alice
.protocol
.data_remove_space(&alice_space)
.expect("remove space");
let announced = alice
.transport
.sent_messages()
.iter()
.filter(|message| !message.metadata.contains_key(ACK_FOR_KEY))
.count();
assert_eq!(
announced, 1,
"removing a space sent one offer per document rather than one for the space"
);
}
#[test]
fn a_refused_blob_leaves_a_removed_name_removed() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
alice.transport.clear_sent_messages();
write(&mut bob, &bob_space, "notes", "added", "B");
bob.transport.clear_sent_messages();
let blob = bob
.protocol
.data_export_snapshot(&bob_space, "notes")
.expect("snapshot");
seed_sync_record(
&mut alice,
&alice_space,
&serde_json::json!({ "quarantined": [blob_digest(&blob)] }),
);
alice.restart(&bob.address);
alice
.protocol
.handle_data_sync_frame(&bob.address, &snapshot_frame("notes", &blob));
assert!(
!holds(&mut alice, &alice_space, "notes"),
"a refused blob created a document"
);
assert!(
matches!(
alice
.protocol
.data_map_get(&alice_space, "notes", "m", "title"),
Err(crate::Error::InvalidState(_))
),
"a refused blob brought the name back without a document under it"
);
}
#[test]
fn a_floor_that_is_not_a_version_is_refused() {
let (mut alice, bob) = pair();
let alice_space = Node::space_for(&bob);
let garbage = BASE64.encode([0xffu8, 0xfe, 0xfd, 0xfc]);
alice.protocol.handle_data_sync_frame(
&bob.address,
&format!(
r#"{{"v":1,"k":"vv","reply":true,"partial":true,"docs":{{}},"gone":{{"notes":"{garbage}"}}}}"#
),
);
assert!(
removal_records(&alice).is_empty(),
"a floor that is not a version was stored"
);
let huge = BASE64.encode(vec![0u8; MAX_REMOVAL_FLOOR_BYTES + 1]);
alice.protocol.handle_data_sync_frame(
&bob.address,
&format!(
r#"{{"v":1,"k":"vv","reply":true,"partial":true,"docs":{{}},"gone":{{"notes":"{huge}"}}}}"#
),
);
assert!(
removal_records(&alice).is_empty(),
"an oversized floor was stored"
);
assert!(
!holds(&mut alice, &alice_space, "notes"),
"a refused floor created a document"
);
}
#[test]
fn removing_a_document_nobody_wrote_to_removes_the_peers_empty_copy() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
alice
.protocol
.data_create_doc(&alice_space, "draft")
.expect("create");
alice.protocol.nudge_data_sync(&alice_space, None, "test");
settle(&mut alice, &mut bob);
assert!(
holds(&mut bob, &bob_space, "draft"),
"precondition: the empty document has to reach the peer"
);
alice
.protocol
.data_remove_doc(&alice_space, "draft")
.expect("remove");
settle(&mut alice, &mut bob);
assert!(!holds(&mut alice, &alice_space, "draft"));
assert!(
!holds(&mut bob, &bob_space, "draft"),
"an empty floor did not remove the peer's empty copy"
);
}
#[test]
fn a_space_whose_documents_were_all_removed_still_lists() {
let mut alice = Node::new("alice");
let space = id("bob");
write(&mut alice, &space, "notes", "k", "v");
alice.protocol.data_remove_space(&space).expect("remove");
assert!(alice
.protocol
.data_list_docs(&space)
.expect("list")
.is_empty());
TestProtocolStateStorage {
storage: alice.state.clone(),
}
.delete(storage_keys::DATA_SPACES, &space)
.expect("delete the index record");
alice.restart(&bob_address());
assert!(
alice
.protocol
.data_list_spaces()
.expect("spaces")
.contains(&space),
"a space with removals to report was left out of the list"
);
}
#[test]
fn re_using_a_removed_name_merges_a_concurrent_edit_into_the_new_document() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "notes", "title", "hello");
settle(&mut alice, &mut bob);
alice
.protocol
.data_remove_doc(&alice_space, "notes")
.expect("remove");
alice
.protocol
.data_create_doc(&alice_space, "notes")
.expect("create again");
write(&mut alice, &alice_space, "notes", "fresh", "new");
write(&mut bob, &bob_space, "notes", "added", "B");
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
alice.protocol.nudge_data_sync(&alice_space, None, "test");
bob.protocol.nudge_data_sync(&bob_space, None, "test");
let rounds = settle(&mut alice, &mut bob);
for (node, space, label) in [
(&mut alice, alice_space.as_str(), "alice"),
(&mut bob, bob_space.as_str(), "bob"),
] {
assert_eq!(
read(node, space, "notes", "fresh"),
Some(DataValue::text("new")),
"{label} lost the new document"
);
assert_eq!(
read(node, space, "notes", "added"),
Some(DataValue::text("B")),
"{label} lost the edit that kept the old contents alive"
);
assert_eq!(
read(node, space, "notes", "title"),
Some(DataValue::text("hello")),
"{label} kept the edit without the contents it was made on, which the \
history cannot express"
);
}
assert_eq!(
rounds.last(),
Some(&0),
"the exchange did not terminate: {rounds:?}"
);
}
fn recording_protocol(label: &str) -> (OfflineProtocol, Arc<Mutex<Vec<String>>>) {
let mut config = create_test_config_for_user(label);
config.encryption.enabled = true;
config.data.enabled = true;
let mut protocol = OfflineProtocol::new(config).expect("protocol");
let writes = Arc::new(Mutex::new(Vec::new()));
protocol
.initialize_mls(
crate::test_identity::seeded_storage(label),
Arc::new(RecordingStateStorage {
inner: TestProtocolStateStorage {
storage: Arc::new(InMemoryStorage::new()),
},
writes: writes.clone(),
}),
)
.expect("initialize_mls");
let mock = MockTransport::new(TransportType::BLE);
mock.start().expect("transport start");
protocol
.transport_manager_mut()
.add_transport(TransportType::BLE, Box::new(mock));
protocol.start().expect("start");
(protocol, writes)
}
fn bob_address() -> String {
id("bob")
}
#[test]
fn a_document_outside_this_devices_interest_is_never_created() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("interest");
write(&mut alice, &alice_space, "wanted.notes", "k", "yes");
write(&mut alice, &alice_space, "other.notes", "k", "no");
let rounds = settle(&mut alice, &mut bob);
assert!(holds(&mut bob, &bob_space, "wanted.notes"));
assert!(
!holds(&mut bob, &bob_space, "other.notes"),
"a document outside the declared interest was stored anyway"
);
assert_eq!(
rounds.last(),
Some(&0),
"the exchange did not terminate: {rounds:?}"
);
}
#[test]
fn a_document_the_asker_never_named_but_wants_is_still_sent() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("interest");
write(&mut alice, &alice_space, "wanted.secret", "k", "v");
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
bob.protocol.nudge_data_sync(&bob_space, None, "test");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "wanted.secret", "k"),
Some(DataValue::text("v")),
"a document the asker wanted but had never named was never announced"
);
}
#[test]
fn an_unwanted_document_is_refused_even_from_a_peer_that_sends_it_anyway() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "other.notes", "k", "no");
write(&mut alice, &alice_space, "wanted.notes", "k", "yes");
settle(&mut alice, &mut bob);
bob.protocol
.data_delete_doc(&bob_space, "wanted.notes")
.expect("evict");
bob.protocol
.data_delete_doc(&bob_space, "other.notes")
.expect("evict");
bob.protocol.peer_data_interest.remove(&alice.address);
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("interest");
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
bob.protocol.nudge_data_sync(&bob_space, None, "test");
pump(&mut bob, &mut alice);
let answers = alice
.transport
.sent_messages()
.iter()
.filter(|message| !message.metadata.contains_key(ACK_FOR_KEY))
.count();
let rounds = settle(&mut alice, &mut bob);
assert_eq!(
answers, 3,
"the answering side sent {answers} frames where three were asked for: a catch-up for \
each of the two documents and one counter-offer. Two is the request going out to a \
peer that never advertised entry 5, which is the gate this test exists for"
);
assert!(holds(&mut bob, &bob_space, "wanted.notes"));
assert!(
!holds(&mut bob, &bob_space, "other.notes"),
"a peer that ignores the request talked this device into storing a document it refused"
);
assert_eq!(
rounds.last(),
Some(&0),
"the exchange with an old peer did not terminate: {rounds:?}"
);
}
#[test]
fn a_declared_interest_stops_the_bytes_leaving_the_peer() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "other.notes", "k", "no");
write(&mut alice, &alice_space, "wanted.notes", "k", "yes");
settle(&mut alice, &mut bob);
bob.protocol
.data_delete_doc(&bob_space, "wanted.notes")
.expect("evict");
bob.protocol
.data_delete_doc(&bob_space, "other.notes")
.expect("evict");
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("interest");
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
bob.protocol.nudge_data_sync(&bob_space, None, "test");
pump(&mut bob, &mut alice);
let answers = alice
.transport
.sent_messages()
.iter()
.filter(|message| !message.metadata.contains_key(ACK_FOR_KEY))
.count();
settle(&mut alice, &mut bob);
assert!(
holds(&mut bob, &bob_space, "wanted.notes"),
"precondition: the wanted document has to arrive"
);
assert_eq!(
answers, 2,
"the answering side sent {answers} frames where two were asked for: a catch-up for \
the wanted document and its counter-offer. A third is the unwanted document going \
out to be dropped on arrival"
);
assert!(
!holds(&mut bob, &bob_space, "other.notes"),
"the unwanted document was stored"
);
}
#[test]
fn widening_an_interest_pulls_the_documents_it_adds() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("interest");
write(&mut alice, &alice_space, "wanted.notes", "k", "yes");
write(&mut alice, &alice_space, "later.notes", "k", "also");
settle(&mut alice, &mut bob);
assert!(!holds(&mut bob, &bob_space, "later.notes"), "precondition");
bob.protocol
.data_set_interest(&bob_space, vec!["*".to_string()])
.expect("widen");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "later.notes", "k"),
Some(DataValue::text("also")),
"widening the interest did not pull the document it added"
);
}
#[test]
fn narrowing_an_interest_keeps_what_is_already_held() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "other.notes", "k", "first");
settle(&mut alice, &mut bob);
assert!(holds(&mut bob, &bob_space, "other.notes"), "precondition");
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("narrow");
write(&mut alice, &alice_space, "other.notes", "k", "second");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "other.notes", "k"),
Some(DataValue::text("first")),
"narrowing threw away a document that was already held, or kept updating it"
);
}
#[test]
fn a_removal_reaches_a_peer_that_no_longer_wants_the_document() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
write(&mut alice, &alice_space, "other.notes", "k", "v");
settle(&mut alice, &mut bob);
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("narrow");
settle(&mut alice, &mut bob);
assert!(holds(&mut bob, &bob_space, "other.notes"), "precondition");
alice
.protocol
.data_remove_doc(&alice_space, "other.notes")
.expect("remove");
settle(&mut alice, &mut bob);
assert!(
!holds(&mut bob, &bob_space, "other.notes"),
"a removal was filtered out by an interest the removed document no longer matched"
);
}
#[test]
fn an_interest_pattern_that_could_never_match_is_refused_where_it_is_written() {
let mut alice = Node::new("alice");
let space = id("bob");
for bad in ["", "has space", "notes/sub", "a*b", "*notes"] {
assert!(
alice
.protocol
.data_set_interest(&space, vec![bad.to_string()])
.is_err(),
"{bad:?} was accepted as an interest pattern"
);
}
for good in ["*", "notes", "draft*", "a.b_c-d"] {
alice
.protocol
.data_set_interest(&space, vec![good.to_string()])
.unwrap_or_else(|err| panic!("{good:?} was refused: {err}"));
}
assert!(
alice
.protocol
.data_set_interest(&space, (0..33).map(|n| format!("d{n}*")).collect())
.is_err(),
"an unbounded pattern list was accepted"
);
}
fn answer_frames(node: &mut Node, sender: &str, body: &str) -> usize {
node.transport.clear_sent_messages();
node.protocol.handle_data_sync_frame(sender, body);
node.transport
.sent_messages()
.iter()
.filter(|message| !message.metadata.contains_key(ACK_FOR_KEY))
.count()
}
fn offer_wanting(patterns: &[String]) -> String {
format!(
"{{\"v\":1,\"k\":\"vv\",\"reply\":false,\"partial\":false,\"docs\":{{}},\"want\":{}}}",
serde_json::to_string(patterns).expect("encode"),
)
}
#[test]
fn an_offer_declaring_more_patterns_than_are_read_is_answered_not_refused() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
write(&mut alice, &alice_space, "notes", "k", "v");
let mut inside: Vec<String> = (0..8).map(|n| format!("filler{n}")).collect();
inside[7] = "notes".to_string();
assert_eq!(
answer_frames(&mut alice, &bob.address, &offer_wanting(&inside)),
2,
"a pattern inside the bound did not draw the document"
);
let mut beyond: Vec<String> = (0..64).map(|n| format!("filler{n}")).collect();
beyond[40] = "notes".to_string();
assert_eq!(
answer_frames(&mut alice, &bob.address, &offer_wanting(&beyond)),
1,
"a peer set its own cost by declaring more interest patterns than the bound reads"
);
}
#[test]
fn wiping_the_store_clears_the_interest_it_scoped() {
let (mut alice, mut bob) = pair();
let alice_space = Node::space_for(&bob);
let bob_space = Node::space_for(&alice);
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("interest");
write(&mut alice, &alice_space, "other.notes", "k", "v");
settle(&mut alice, &mut bob);
assert!(
!holds(&mut bob, &bob_space, "other.notes"),
"precondition: the narrowing has to be refusing the document"
);
bob.protocol.data_wipe_all().expect("wipe");
bob.protocol.nudge_data_sync(&bob_space, None, "test");
settle(&mut alice, &mut bob);
assert_eq!(
read(&mut bob, &bob_space, "other.notes", "k"),
Some(DataValue::text("v")),
"a narrowing outlived the wipe that cleared the documents it scoped"
);
}
#[test]
fn declaring_interest_before_start_does_not_spend_the_offer_window() {
let mut config = create_test_config_for_user("alice");
config.encryption.enabled = true;
config.data.enabled = true;
let mut protocol = OfflineProtocol::new(config).expect("protocol");
let state = Arc::new(InMemoryStorage::new());
protocol
.initialize_mls(
crate::test_identity::seeded_storage("alice"),
Arc::new(TestProtocolStateStorage {
storage: state.clone(),
}),
)
.expect("initialize_mls");
let peer = bob_address();
protocol.peer_data_sync.insert(peer.clone());
protocol.peer_data_interest.insert(peer.clone());
protocol
.data_set_interest(&peer, vec!["wanted*".to_string()])
.expect("interest");
assert!(
!protocol.last_data_sync_offer.contains_key(&peer),
"an interest declared before start() spent the window that suppresses \
the sweep start() makes"
);
protocol.start().expect("start");
protocol
.data_set_interest(&peer, vec!["wanted*".to_string(), "later*".to_string()])
.expect("widen");
assert!(
protocol.last_data_sync_offer.contains_key(&peer),
"widening the interest of a running engine did not ask the peer for what it added"
);
protocol.last_data_sync_offer.clear();
protocol
.data_set_interest(&peer, vec!["wanted*".to_string(), "later*".to_string()])
.expect("same patterns again");
assert!(
protocol.last_data_sync_offer.is_empty(),
"re-declaring the same interest spent a window on an offer that would have \
carried exactly what the last one did"
);
}
#[test]
fn a_carried_snapshot_outside_the_interest_is_refused_before_it_is_reassembled() {
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("interest");
let payload = vec![7u8; 2 * 1024 * 1024];
alice
.protocol
.send_media_inner(
bob.address.clone(),
payload,
"other.notes".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "other.notes".to_string(),
}),
)
.expect("a peer that advertised carriage accepts the send");
pump(&mut alice, &mut bob);
assert_eq!(
bob.protocol.file_transfer_manager.active_transfer_count(),
0,
"a narrowed device buffered a snapshot for a document it had already refused"
);
let (mut alice, mut bob) = pair();
let bob_space = Node::space_for(&alice);
bob.protocol
.data_set_interest(&bob_space, vec!["wanted*".to_string()])
.expect("interest");
let payload = vec![7u8; 2 * 1024 * 1024];
alice
.protocol
.send_media_inner(
bob.address.clone(),
payload,
"wanted.notes".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "wanted.notes".to_string(),
}),
)
.expect("send");
pump(&mut alice, &mut bob);
assert_eq!(
bob.protocol.file_transfer_manager.active_transfer_count(),
1,
"the same transfer must be admitted for a document inside the interest"
);
}
fn no_alert(message: &offline_protocol_core::Message) -> Option<&str> {
message
.metadata
.get(crate::constants::NO_ALERT_KEY)
.map(String::as_str)
}
fn drain_sent(node: &Node) -> Vec<offline_protocol_core::Message> {
let sent = node.transport.sent_messages();
node.transport.clear_sent_messages();
sent
}
#[test]
fn frames_the_sdk_sends_itself_are_marked_no_alert_and_user_messages_are_not() {
let (mut alice, bob) = pair();
let space = Node::space_for(&bob);
let bob_id = bob.address.clone();
write(&mut alice, &space, "notes", "title", "hello");
alice.protocol.kick_data_sync(&space, "peer_rediscovered");
let sync = drain_sent(&alice);
assert!(sync.len() >= 2, "a delta and an offer, got {}", sync.len());
alice.protocol.send_key_package_to(&bob_id, false).unwrap();
alice
.protocol
.send_read_receipt(
&bob_id,
vec![offline_protocol_core::MessageId::new().as_str()],
)
.unwrap();
alice
.protocol
.send_typing_indicator(&bob_id, "chat", true)
.unwrap();
alice.protocol.reject_connection_request(&bob_id).unwrap();
let control = drain_sent(&alice);
assert_eq!(control.len(), 4);
for message in sync.iter().chain(&control) {
assert_eq!(no_alert(message), Some("1"), "{:.40}", message.content);
}
alice.protocol.peer_rich_payload.insert(bob_id.clone());
let plain = alice
.protocol
.send_message(&bob_id, "plain", None, None::<String>)
.unwrap();
let rich = alice
.protocol
.send_message_with(
&bob_id,
"rich",
crate::SendMessageOptions {
reply_context: Some(offline_protocol_core::ReplyContext {
sender: offline_protocol_core::UserId::new(&bob_id).unwrap(),
text: "quoted".to_string(),
timestamp: None,
reply_media_label: None,
reply_content_type: Some("text".to_string()),
}),
..Default::default()
},
)
.unwrap();
let mut original = alice.protocol.outbox[&plain].message.clone();
original.content = "forwarded".to_string();
alice
.protocol
.forward_message(&original, &bob_id, None)
.unwrap();
alice
.protocol
.send_connection_request(&bob_id, "Alice", None, None)
.unwrap();
alice
.protocol
.accept_connection_request(&bob_id, "Alice", None)
.unwrap();
let user = drain_sent(&alice);
assert_eq!(user.len(), 5);
for message in &user {
assert_eq!(no_alert(message), None, "{:.40}", message.content);
}
assert!(user.iter().any(|m| m.id == rich));
}
#[test]
fn a_resent_or_restored_sync_frame_keeps_its_no_alert_marker() {
let (mut alice, bob) = pair();
let space = Node::space_for(&bob);
write(&mut alice, &space, "notes", "title", "hello");
let frame = drain_sent(&alice).pop().expect("a delta");
assert_eq!(no_alert(&frame), Some("1"));
let mut resend = alice.protocol.outbox[&frame.id].message.clone();
alice.protocol.reseal_for_resend_in_place(&mut resend);
assert_ne!(
resend.content, frame.content,
"the resend was not re-sealed"
);
assert_eq!(no_alert(&resend), Some("1"));
alice.restart(&bob.address);
let restored = &alice.protocol.outbox[&frame.id].message;
assert_eq!(no_alert(restored), Some("1"));
}
#[test]
fn a_document_carried_as_media_is_marked_no_alert_and_a_users_file_is_not() {
let (mut alice, bob) = pair();
let bytes = vec![7u8; 100 * 1024];
let send = |alice: &mut Node, purpose| {
alice
.protocol
.send_media_inner(
bob.address.clone(),
bytes.clone(),
"notes".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
purpose,
)
.expect("the transfer starts");
drain_sent(alice)
.into_iter()
.filter(|m| m.content_type == offline_protocol_core::ContentType::FileChunk)
.collect::<Vec<_>>()
};
let snapshot = send(
&mut alice,
Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "notes".to_string(),
}),
);
assert!(snapshot.len() > 1, "{} chunks", snapshot.len());
for chunk in &snapshot {
assert_eq!(no_alert(chunk), Some("1"));
}
let file = send(&mut alice, None);
assert!(!file.is_empty());
for chunk in &file {
assert_eq!(no_alert(chunk), None);
}
}
fn hold_key_package(node: &mut Node, peer: &str) {
let kp = crate::test_identity::manager_for(peer, Arc::new(InMemoryStorage::new()))
.get_or_create_key_package()
.unwrap();
node.protocol.pending_key_packages.insert(
id(peer),
crate::protocol::types::ReceivedKeyPackage {
key_package_data: kp.key_package_data,
local_expires_at_ms: chrono::Utc::now().timestamp_millis() as u64 + 60_000,
},
);
}
fn sent_welcome(node: &Node) -> offline_protocol_core::Message {
drain_sent(node)
.into_iter()
.rev()
.find(|m| {
m.content
.starts_with(crate::protocol::prefixes::internal_prefixes::WELCOME)
})
.expect("a Welcome on the wire")
}
#[test]
fn a_welcome_alerts_only_while_a_user_message_waits_on_it() {
let (mut alice, _bob) = pair();
hold_key_package(&mut alice, "carol");
let carol = id("carol");
alice
.protocol
.send_message(&carol, "hi", None, None::<String>)
.unwrap();
assert!(!alice.protocol.pending_encrypted_messages[&carol].is_empty());
assert_eq!(no_alert(&sent_welcome(&alice)), None);
alice
.protocol
.try_send_welcome(&carol, "test_retry", false)
.unwrap();
assert_eq!(no_alert(&sent_welcome(&alice)), None);
alice.protocol.pending_encrypted_messages.remove(&carol);
alice
.protocol
.try_send_welcome(&carol, "test_retry", false)
.unwrap();
assert_eq!(no_alert(&sent_welcome(&alice)), Some("1"));
hold_key_package(&mut alice, "dave");
alice
.protocol
.establish_secure_session(&id("dave"))
.unwrap()
.expect("a session was created");
assert_eq!(no_alert(&sent_welcome(&alice)), Some("1"));
let mut open_with_media = |peer: &str, purpose| {
hold_key_package(&mut alice, peer);
let peer = id(peer);
alice.protocol.peer_data_sync.insert(peer.clone());
alice.protocol.peer_data_media.insert(peer.clone());
let err = alice
.protocol
.send_media_inner(
peer,
b"bytes".to_vec(),
"notes".to_string(),
offline_protocol_core::ContentType::File,
crate::protocol::types::MediaSendOptions::default(),
purpose,
)
.expect_err("no confirmed session yet");
assert!(matches!(err, crate::Error::SessionNotReady(_)), "{err}");
sent_welcome(&alice)
};
assert_eq!(no_alert(&open_with_media("erin", None)), None);
let snapshot = Some(crate::media_envelope::DataPurpose::Snapshot {
doc: "notes".to_string(),
});
assert_eq!(no_alert(&open_with_media("frank", snapshot)), Some("1"));
}