use std::sync::{Arc, Mutex};
use offline_protocol_data::DataValue;
use offline_protocol_transport::{MockTransport, Transport, TransportType};
use crate::group_mesh::{RosterRatchetGap, MAX_ROSTER_INVISIBLE_GROUP_GENERATIONS};
use crate::mls::InMemoryStorage;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use crate::protocol::data_sync::{SyncChannel, MAX_GROUP_BLOB_CHUNKS, MAX_SYNC_BLOB_BYTES};
use crate::protocol::prefixes::internal_prefixes;
use crate::protocol::tests::{create_test_config_for_user, id};
use crate::protocol::types::{
DATA_CUSTODY_V1, DATA_GROUP_BLOB_V1, DATA_GROUP_V1, DATA_INTEREST_V1, DATA_MEDIA_V1,
DATA_SYNC_V1, DATA_TOMBSTONE_V1,
};
use crate::protocol::{OfflineProtocol, TestProtocolStateStorage};
struct Member {
protocol: OfflineProtocol,
transport: MockTransport,
address: String,
events: Arc<Mutex<Vec<crate::Event>>>,
}
impl Member {
fn new(label: &str) -> 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,
Arc::new(TestProtocolStateStorage { storage: state }),
)
.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");
Self {
protocol,
transport,
address: id(label),
events,
}
}
fn saw_group_message(&self) -> bool {
self.events
.lock()
.unwrap()
.iter()
.any(|event| matches!(event, crate::Event::GroupMessageReceived { .. }))
}
}
fn trio() -> (Member, Member, Member, String) {
let mut alice = Member::new("alice");
let mut bob = Member::new("bob");
let mut carol = Member::new("carol");
let group_id = {
let info = alice.protocol.create_group("notes").expect("create");
info.group_id.as_str().to_string()
};
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
add_member(&alice, &bob, &gid, &[]);
add_member(&alice, &carol, &gid, &[&bob]);
alice.protocol.refresh_group_members(&group_id).unwrap();
let roster = vec![
alice.address.clone(),
bob.address.clone(),
carol.address.clone(),
];
for member in [&mut bob, &mut carol] {
member
.protocol
.group_mesh
.members
.insert(group_id.clone(), roster.clone());
}
let (a, b, c) = (
alice.address.clone(),
bob.address.clone(),
carol.address.clone(),
);
for (member, others) in [
(&mut alice, [&b, &c]),
(&mut bob, [&a, &c]),
(&mut carol, [&a, &b]),
] {
for other in others {
member.protocol.peer_data_group.insert(other.clone());
member.protocol.peer_data_group_blob.insert(other.clone());
}
}
(alice, bob, carol, group_id)
}
fn add_member(
adder: &Member,
joiner: &Member,
gid: &offline_protocol_mls::GroupId,
existing: &[&Member],
) {
let kp = {
let mls = joiner.protocol.mls_manager_for_testing().read().unwrap();
mls.generate_key_package().unwrap()
};
let (welcome, commit) = {
let mls = adder.protocol.mls_manager_for_testing().read().unwrap();
mls.add_group_member(gid, &joiner.address, &kp.key_package_data)
.unwrap()
};
{
let mls = joiner.protocol.mls_manager_for_testing().read().unwrap();
mls.join_group(&welcome).unwrap();
}
for member in existing {
let mls = member.protocol.mls_manager_for_testing().read().unwrap();
let encrypted = offline_protocol_mls::EncryptedMessage {
group_id: gid.clone(),
message_type: offline_protocol_mls::MlsMessageType::Commit,
epoch: commit.epoch,
ciphertext: commit.ciphertext.clone(),
sender_id: adder.address.clone(),
timestamp_ms: 0,
};
mls.decrypt_from_group(&encrypted, &adder.address)
.expect("merge the add commit");
}
}
fn pump(from: &mut Member, to: &mut [&mut Member]) -> usize {
let messages = from.transport.sent_messages();
from.transport.clear_sent_messages();
let mut moved = 0;
for message in messages {
for peer in to.iter_mut() {
if message.recipient.as_str() == peer.address {
peer.transport.queue_message(message.clone());
moved += 1;
}
}
}
for peer in to.iter_mut() {
while peer.protocol.receive_message().is_some() {}
}
moved
}
fn settle(alice: &mut Member, bob: &mut Member, carol: &mut Member) -> Vec<usize> {
let mut rounds = Vec::new();
for _ in 0..8 {
let mut carried = 0;
carried += {
let (b, c) = (&mut *bob, &mut *carol);
pump(alice, &mut [b, c])
};
carried += {
let (a, c) = (&mut *alice, &mut *carol);
pump(bob, &mut [a, c])
};
carried += {
let (a, b) = (&mut *alice, &mut *bob);
pump(carol, &mut [a, b])
};
rounds.push(carried);
if carried == 0 {
break;
}
}
rounds
}
fn write(member: &mut Member, space: &str, doc: &str, key: &str, value: &str) {
member
.protocol
.data_map_set(space, doc, "m", key, DataValue::text(value))
.expect("set");
member.protocol.data_flush(space, doc).expect("flush");
}
fn read(member: &mut Member, space: &str, doc: &str, key: &str) -> Option<DataValue> {
member
.protocol
.data_map_get(space, doc, "m", key)
.expect("get")
}
fn data_frames_sent(from: &Member, to: &mut Member, group_id: &str) -> usize {
from.transport
.sent_messages()
.iter()
.filter(|message| message.recipient.as_str() == to.address)
.filter(|message| {
let Some(payload) = message
.content
.strip_prefix(internal_prefixes::GROUP_MLS_MSG)
else {
return false;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(payload) else {
return false;
};
let Some(ciphertext) = value.get("ciphertext").and_then(|c| c.as_str()) else {
return false;
};
let Ok(bytes) = crate::protocol::base64_decode(ciphertext) else {
return false;
};
let mls = to.protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(group_id).unwrap();
let encrypted = offline_protocol_mls::EncryptedMessage {
group_id: gid,
message_type: offline_protocol_mls::MlsMessageType::Application,
epoch: 0,
ciphertext: bytes,
sender_id: from.address.clone(),
timestamp_ms: 0,
};
matches!(
mls.decrypt_from_group(&encrypted, &from.address),
Ok(Some(plaintext))
if String::from_utf8_lossy(&plaintext)
.starts_with(internal_prefixes::DATA_V1)
)
})
.count()
}
#[test]
fn a_change_written_in_a_group_reaches_every_member() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
for (label, member) in [("bob", &mut bob), ("carol", &mut carol)] {
assert_eq!(
read(member, &group, "notes", "title"),
Some(DataValue::text("hello")),
"{label} never received the change"
);
}
}
#[test]
fn a_group_change_is_encrypted_once_for_the_whole_roster() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
let to_bob = data_frames_sent(&alice, &mut bob, &group);
let to_carol = data_frames_sent(&alice, &mut carol, &group);
assert_eq!(
(to_bob, to_carol),
(1, 1),
"one commit must produce exactly one frame per member"
);
let ciphertexts: Vec<String> = alice
.transport
.sent_messages()
.iter()
.filter_map(|m| {
let payload = m.content.strip_prefix(internal_prefixes::GROUP_MLS_MSG)?;
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
Some(value.get("ciphertext")?.as_str()?.to_string())
})
.collect();
assert_eq!(ciphertexts.len(), 2, "one copy addressed to each member");
assert_eq!(
ciphertexts[0], ciphertexts[1],
"both members must be handed the SAME ciphertext: encrypting per \
member would make a group of N cost N encryptions and N times the \
key material, which is the whole reason a group space exists"
);
}
#[test]
fn a_change_received_from_the_group_is_not_pushed_on() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
{
let (b, c) = (&mut bob, &mut carol);
pump(&mut alice, &mut [b, c]);
}
assert_eq!(
read(&mut bob, &group, "notes", "title"),
Some(DataValue::text("hello")),
"the change has to have arrived for its echo to be meaningful"
);
let echoed_to_carol = data_frames_sent(&bob, &mut carol, &group);
assert_eq!(
echoed_to_carol, 0,
"Bob re-broadcast a change he received from the group; Carol \
already had it from Alice's own frame"
);
}
#[test]
fn a_member_that_cannot_intercept_group_frames_is_never_sent_one() {
let (mut alice, mut bob, mut carol, group) = trio();
alice.protocol.peer_data_group.remove(&carol.address);
write(&mut alice, &group, "notes", "title", "hello");
assert_eq!(
data_frames_sent(&alice, &mut bob, &group),
0,
"the gate is all-members: a frame Bob can read is delivered to \
Carol too, and she would render it as text"
);
settle(&mut alice, &mut bob, &mut carol);
assert_eq!(
read(&mut bob, &group, "notes", "title"),
None,
"nothing may replicate while a member of unknown capability is in \
the roster"
);
}
#[test]
fn the_group_capability_is_independent_of_the_one_to_one_one() {
let mut protocol = OfflineProtocol::new({
let mut config = create_test_config_for_user("alice");
config.encryption.enabled = true;
config.data.enabled = true;
config
})
.unwrap();
protocol
.initialize_mls_for_test(Arc::new(InMemoryStorage::new()))
.unwrap();
protocol.peer_data_sync.insert(id("legacy"));
assert!(
protocol.data_sync_active(&id("legacy")),
"1:1 replication must work with a peer that predates group spaces"
);
assert!(
!protocol.group_data_sync_active(&[id("alice"), id("legacy")]),
"the same peer must hold the group gate closed"
);
protocol.peer_data_group.insert(id("current"));
protocol.peer_data_sync.insert(id("current"));
assert!(protocol.group_data_sync_active(&[id("alice"), id("current")]));
}
#[test]
fn a_group_space_is_named_by_its_group_id() {
let (mut alice, _bob, _carol, group) = trio();
assert!(group.starts_with("group:"), "group ids carry a colon");
write(&mut alice, &group, "notes", "title", "hello");
assert_eq!(
read(&mut alice, &group, "notes", "title"),
Some(DataValue::text("hello"))
);
assert!(
alice.protocol.data_list_spaces().unwrap().contains(&group),
"the space has to be listed under the name it was written with"
);
}
#[test]
fn a_joining_member_catches_up_on_documents_written_before_it_arrived() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
let mut dave = Member::new("dave");
let gid = offline_protocol_mls::GroupId::new(&group).unwrap();
let kp = {
let mls = dave.protocol.mls_manager_for_testing().read().unwrap();
mls.generate_key_package().unwrap()
};
let (welcome, _commit) = {
let mls = alice.protocol.mls_manager_for_testing().read().unwrap();
mls.add_group_member(&gid, &dave.address, &kp.key_package_data)
.unwrap()
};
{
let mls = dave.protocol.mls_manager_for_testing().read().unwrap();
mls.join_group(&welcome).unwrap();
}
alice.protocol.refresh_group_members(&group).unwrap();
dave.protocol.group_mesh.members.insert(
group.clone(),
vec![
alice.address.clone(),
bob.address.clone(),
carol.address.clone(),
dave.address.clone(),
],
);
for other in [&bob.address, &carol.address, &dave.address] {
alice.protocol.peer_data_group.insert(other.clone());
alice.protocol.peer_data_group_blob.insert(other.clone());
}
for other in [&alice.address, &bob.address, &carol.address] {
dave.protocol.peer_data_group.insert(other.clone());
dave.protocol.peer_data_group_blob.insert(other.clone());
}
alice
.protocol
.kick_group_data_sync(&group, &dave.address, "member_added");
for _ in 0..6 {
let mut carried = 0;
carried += {
let (d, b) = (&mut dave, &mut bob);
pump(&mut alice, &mut [d, b])
};
carried += {
let (a, b) = (&mut alice, &mut bob);
pump(&mut dave, &mut [a, b])
};
if carried == 0 {
break;
}
}
assert_eq!(
read(&mut dave, &group, "notes", "title"),
Some(DataValue::text("hello")),
"a member that joined after the writing never received the document"
);
}
#[test]
fn an_answer_goes_to_the_member_that_asked_and_to_nobody_else() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
bob.protocol
.kick_group_data_sync(&group, &alice.address, "test_offer");
{
let (a, c) = (&mut alice, &mut carol);
pump(&mut bob, &mut [a, c])
};
assert_eq!(
data_frames_sent(&alice, &mut carol, &group),
0,
"Carol was sent an answer to a question Bob asked"
);
}
#[test]
fn edits_made_apart_converge_across_the_group_and_the_exchange_terminates() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "a", "1");
settle(&mut alice, &mut bob, &mut carol);
write(&mut alice, &group, "notes", "from_alice", "x");
write(&mut bob, &group, "notes", "from_bob", "y");
write(&mut carol, &group, "notes", "from_carol", "z");
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
carol.transport.clear_sent_messages();
alice
.protocol
.kick_group_data_sync(&group, &bob.address, "reconnect");
alice
.protocol
.kick_group_data_sync(&group, &carol.address, "reconnect");
bob.protocol
.kick_group_data_sync(&group, &carol.address, "reconnect");
let rounds = settle(&mut alice, &mut bob, &mut carol);
for (label, member) in [
("alice", &mut alice),
("bob", &mut bob),
("carol", &mut carol),
] {
for key in ["from_alice", "from_bob", "from_carol"] {
assert!(
read(member, &group, "notes", key).is_some(),
"{label} is missing {key} after reconnecting"
);
}
}
assert_eq!(
rounds.last(),
Some(&0),
"the exchange has to stop: replicas that answer each other's \
answers converge and then keep talking forever (rounds: {rounds:?})"
);
}
#[test]
fn group_replication_stops_when_the_layer_is_switched_off() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
alice.transport.clear_sent_messages();
alice.protocol.config.data.enabled = false;
assert!(
alice
.protocol
.data_map_set(&group, "notes", "m", "title", DataValue::text("again"))
.is_err(),
"a switched-off layer refuses writes"
);
alice
.protocol
.kick_group_data_sync(&group, &bob.address, "kill_switch");
assert_eq!(
data_frames_sent(&alice, &mut bob, &group),
0,
"the kill switch has to gate the group path too"
);
}
fn group_data_frame(from: &mut Member, group: &str, doc: &str, key: &str, value: &str) -> String {
write(from, group, doc, key, value);
let frame = from
.transport
.sent_messages()
.iter()
.find_map(|m| {
let payload = m.content.strip_prefix(internal_prefixes::GROUP_MLS_MSG)?;
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
Some(value.get("ciphertext")?.as_str()?.to_string())
})
.expect("the write produced a group replication frame");
from.transport.clear_sent_messages();
frame
}
#[test]
fn a_replication_frame_delivered_by_the_relay_is_intercepted() {
let (mut alice, mut bob, _carol, group) = trio();
let ciphertext = group_data_frame(&mut alice, &group, "notes", "title", "hello");
bob.protocol.handle_relay_group_message_with_mls(
&group,
&alice.address,
&ciphertext,
"2026-08-20T00:00:00Z",
"relay-msg-1",
None,
None,
);
assert_eq!(
read(&mut bob, &group, "notes", "title"),
Some(DataValue::text("hello")),
"a replication frame delivered by the relay was not applied"
);
assert!(
!bob.saw_group_message(),
"the frame was surfaced to the application as a chat message"
);
}
#[test]
fn a_replication_frame_buffered_before_the_group_was_ready_is_intercepted_on_drain() {
let (mut alice, mut bob, _carol, group) = trio();
let ciphertext = group_data_frame(&mut alice, &group, "notes", "title", "hello");
bob.protocol.buffer_pending_group_message(
&group,
crate::group_mesh::PendingGroupMessage {
sender: alice.address.clone(),
message_id: "buffered-1".to_string(),
logical_id: None,
ciphertext_b64: ciphertext,
timestamp: Some("2026-08-20T00:00:00Z".to_string()),
reply_to: None,
forward_info: None,
buffered_at: std::time::Instant::now(),
received_via: None,
},
);
bob.protocol.drain_pending_group_messages(&group);
assert_eq!(
read(&mut bob, &group, "notes", "title"),
Some(DataValue::text("hello")),
"a replication frame drained from the pending buffer was not applied"
);
assert!(
!bob.saw_group_message(),
"the drained frame was surfaced to the application as a chat message"
);
}
#[test]
fn the_group_capability_is_advertised_and_recorded() {
let (alice, _bob, _carol, _group) = trio();
assert_eq!(
alice.protocol.advertised_data_versions(),
vec![
DATA_SYNC_V1,
DATA_GROUP_V1,
DATA_MEDIA_V1,
DATA_TOMBSTONE_V1,
DATA_INTEREST_V1,
DATA_GROUP_BLOB_V1,
DATA_CUSTODY_V1
],
"a build that intercepts group frames has to say so, or no peer \
will ever send it one. The media entry rides the same list and is \
read independently: it says nothing about groups, because v1 \
carries blobs on 1:1 sessions only. The removal entry is read \
independently too, and a group carries removals without consulting \
it: one ciphertext reaches the whole roster, so there is no \
per-member choice to make"
);
}
#[test]
fn the_shared_group_sweep_finds_a_group_on_a_cold_roster_cache() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
alice.transport.clear_sent_messages();
alice.protocol.group_mesh.members.clear();
alice.protocol.last_data_sync_offer.clear();
alice.protocol.group_spaces_enumerated = false;
alice
.protocol
.kick_shared_group_data_sync(&bob.address, "peer_rediscovered");
assert!(
data_frames_sent(&alice, &mut bob, &group) > 0,
"a rediscovered member of a group this device is in has to be \
offered its documents, and which groups those are comes from MLS, \
not from a cache that a restart emptied"
);
}
#[test]
fn a_space_named_after_a_one_to_one_session_slot_is_not_a_group_space() {
let alice = Member::new("alice");
crate::protocol::tests::create_local_session_with(&alice.protocol, "bob");
let slot = crate::test_identity::session_slot("alice", "bob");
{
let mls = alice.protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&slot).unwrap();
assert!(
mls.has_group(&gid).unwrap(),
"precondition: MLS really does hold a group at the session slot"
);
}
let mut alice = alice;
assert!(
alice.protocol.group_space_roster(&slot).is_none(),
"a session slot must never classify as a group space"
);
assert!(
!alice.protocol.group_mesh.members.contains_key(&slot),
"and classifying it must not have filed it in the roster cache"
);
}
#[test]
fn directed_frames_do_not_strand_the_member_they_skip() {
let (mut alice, mut bob, mut carol, group) = trio();
let hidden = MAX_ROSTER_INVISIBLE_GROUP_GENERATIONS as usize * 5;
for _ in 0..hidden {
alice.protocol.last_data_sync_offer.clear();
alice
.protocol
.kick_group_data_sync(&group, &bob.address, "rediscovered");
}
assert!(
hidden > 1000,
"the run has to exceed the forward-distance limit or this test \
passes for the wrong reason (hidden: {hidden})"
);
settle(&mut alice, &mut bob, &mut carol);
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
assert_eq!(
read(&mut carol, &group, "notes", "title"),
Some(DataValue::text("hello")),
"Carol could not decrypt a frame addressed to the whole roster: the \
directed traffic she never saw ran the group's sender ratchet out of \
her reach, which costs her every later message from Alice and not \
just this one"
);
}
fn ratchet_gap(member: &Member, group: &str) -> Option<RosterRatchetGap> {
member
.protocol
.group_mesh
.roster_invisible_generations
.get(group)
.copied()
}
fn spend_the_budget(member: &mut Member, group: &str) {
let epoch = ratchet_gap(member, group)
.expect("the group has to have been sent something before its budget can be spent")
.epoch;
member
.protocol
.group_mesh
.roster_invisible_generations
.insert(
group.to_string(),
RosterRatchetGap {
epoch,
invisible: MAX_ROSTER_INVISIBLE_GROUP_GENERATIONS,
},
);
}
#[test]
fn a_roster_wide_frame_clears_the_ratchet_gap_budget() {
let (mut alice, bob, _carol, group) = trio();
for _ in 0..10 {
alice.protocol.last_data_sync_offer.clear();
alice
.protocol
.kick_group_data_sync(&group, &bob.address, "rediscovered");
}
assert_eq!(
ratchet_gap(&alice, &group).map(|gap| gap.invisible),
Some(9),
"each directed frame has to be counted, or the budget never trips. \
Nine and not ten because the first frame of the process is promoted \
on its own account: this device cannot see the generation it \
inherited from earlier sessions"
);
alice
.protocol
.send_group_message(&group, "an ordinary message", None, None)
.expect("send a group message");
assert_eq!(
ratchet_gap(&alice, &group).map(|gap| gap.invisible),
Some(0),
"a message every member is handed puts the whole roster back within \
reach, so the gap it closed must not keep counting against the next \
promotion"
);
assert!(
ratchet_gap(&alice, &group).is_some(),
"and the entry has to stay: an absent one means \"this process has \
never given the roster a frame\", which would promote every frame \
from here on"
);
}
#[test]
fn the_first_directed_frame_of_a_process_is_promoted() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
alice
.protocol
.group_mesh
.roster_invisible_generations
.clear();
alice.transport.clear_sent_messages();
alice.protocol.last_data_sync_offer.clear();
alice
.protocol
.kick_group_data_sync(&group, &bob.address, "rediscovered");
assert!(
data_frames_sent(&alice, &mut carol, &group) > 0,
"the first directed frame after a relaunch has to reach the whole \
roster: it is the only rung this process can be sure every member \
can still decrypt"
);
assert_eq!(
ratchet_gap(&alice, &group).map(|gap| gap.invisible),
Some(0),
"and having promoted it, the count starts from a gap of nothing"
);
}
#[test]
fn an_epoch_rotation_rebases_the_ratchet_gap() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
let epoch = ratchet_gap(&alice, &group)
.expect("the write recorded an epoch")
.epoch;
alice
.protocol
.group_mesh
.roster_invisible_generations
.insert(
group.clone(),
RosterRatchetGap {
epoch: epoch.saturating_sub(1),
invisible: MAX_ROSTER_INVISIBLE_GROUP_GENERATIONS,
},
);
alice.transport.clear_sent_messages();
alice.protocol.last_data_sync_offer.clear();
alice
.protocol
.kick_group_data_sync(&group, &bob.address, "rediscovered");
assert_eq!(
data_frames_sent(&alice, &mut carol, &group),
0,
"a gap recorded in an epoch the group has left must not spend a \
roster-wide frame to close a ratchet every member already restarted"
);
let gap = ratchet_gap(&alice, &group).expect("the directed frame was counted");
assert_eq!(
gap.epoch, epoch,
"the count has to move to the epoch it now describes"
);
assert_eq!(
gap.invisible, 1,
"and start from that epoch's first invisible frame"
);
}
#[test]
fn a_promotion_never_crosses_a_closed_replication_gate() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
spend_the_budget(&mut alice, &group);
let spent = ratchet_gap(&alice, &group).expect("the budget was just spent");
alice.protocol.peer_data_group.remove(&carol.address);
alice.transport.clear_sent_messages();
bob.protocol.last_data_sync_offer.clear();
bob.protocol
.kick_group_data_sync(&group, &alice.address, "rediscovered");
{
let (a, c) = (&mut alice, &mut carol);
pump(&mut bob, &mut [a, c]);
}
assert_eq!(
data_frames_sent(&alice, &mut carol, &group),
0,
"the ratchet budget promoted a replication frame into a roster whose \
gate is closed; Carol renders it to her user as literal __DATA_V1__ \
text, which is the whole reason the gate exists"
);
assert_eq!(
data_frames_sent(&alice, &mut bob, &group),
0,
"with the promotion unavailable the answer has to be withheld, not \
sent addressed: another generation only Bob observes is one more \
step toward Carol losing this device's chat as well"
);
assert_eq!(
ratchet_gap(&alice, &group).map(|gap| gap.invisible),
Some(spent.invisible),
"a withheld frame must not be counted, or the refusal spends the \
very budget it exists to stop spending"
);
alice.protocol.peer_data_group.insert(carol.address.clone());
alice.transport.clear_sent_messages();
bob.protocol.last_data_sync_offer.clear();
bob.protocol
.kick_group_data_sync(&group, &alice.address, "rediscovered");
{
let (a, c) = (&mut alice, &mut carol);
pump(&mut bob, &mut [a, c]);
}
assert!(
data_frames_sent(&alice, &mut carol, &group) > 0,
"once every member can read one, the spent budget has to promote the \
answer to the whole roster: that is the rung Carol climbs"
);
}
#[test]
fn a_closed_gate_probes_the_member_holding_it_shut() {
let (mut alice, bob, _carol, group) = trio();
alice.protocol.peer_data_group.remove(&bob.address);
alice.protocol.key_package_sent_to.clear();
alice
.protocol
.kick_group_data_sync(&group, &bob.address, "rediscovered");
assert!(
alice
.protocol
.key_package_sent_to
.contains_key(&bob.address),
"a member of unknown capability has to be asked what it supports; \
our key package is the question, and its automatic reply is the \
answer"
);
}
#[test]
fn forgetting_a_peer_drops_its_group_offer_windows_too() {
let (mut alice, bob, _carol, group) = trio();
alice
.protocol
.kick_group_data_sync(&group, &bob.address, "rediscovered");
alice.protocol.kick_data_sync(&bob.address, "rediscovered");
assert!(
alice
.protocol
.last_data_sync_offer
.keys()
.any(|k| k.contains(&group)),
"precondition: the group offer has to have stamped a window"
);
alice.protocol.forget_data_sync_peer(&bob.address);
assert!(
!alice
.protocol
.last_data_sync_offer
.keys()
.any(|k| k.starts_with(bob.address.as_str())),
"every window belonging to the forgotten peer has to go, the group \
ones included"
);
}
#[test]
fn a_member_that_narrows_its_interest_refuses_what_the_group_broadcasts() {
let (mut alice, mut bob, mut carol, group) = trio();
carol
.protocol
.data_set_interest(&group, vec!["wanted*".to_string()])
.expect("interest");
write(&mut alice, &group, "wanted.notes", "k", "yes");
write(&mut alice, &group, "other.notes", "k", "no");
let rounds = settle(&mut alice, &mut bob, &mut carol);
let carol_docs = carol.protocol.data_list_docs(&group).expect("list");
assert!(
carol_docs.iter().any(|doc| doc == "wanted.notes"),
"the document the narrowing asked for never arrived: {carol_docs:?}"
);
assert!(
!carol_docs.iter().any(|doc| doc == "other.notes"),
"a document outside the declared interest was stored from a group broadcast: \
{carol_docs:?}"
);
assert_eq!(
read(&mut bob, &group, "other.notes", "k"),
Some(DataValue::text("no")),
"one member's interest narrowed what another member stores"
);
assert_eq!(
rounds.last(),
Some(&0),
"the group exchange did not terminate: {rounds:?}"
);
}
#[test]
fn a_group_fetch_has_to_name_the_member_it_asks() {
let (mut alice, _bob, _carol, group) = trio();
let hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let err = alice
.protocol
.data_fetch_attachment(&group, hash)
.expect_err("an unaddressed group fetch must refuse");
assert!(
format!("{err}").contains("data_fetch_attachment_from"),
"the refusal must name the call that works: {err}"
);
let err = alice
.protocol
.data_fetch_attachment_from(&group, &id("stranger"), hash)
.expect_err("a fetch from a non-member must refuse");
assert!(
format!("{err}").contains("not a member"),
"the refusal must name the reason: {err}"
);
}
#[test]
fn a_group_document_too_large_to_frame_is_reported_not_carried() {
let (mut alice, mut bob, mut carol, group) = trio();
let mut seed = 0x2545_F491_4F6C_DD1Du64;
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();
alice
.protocol
.data_map_set(
&group,
"notes",
"m",
&format!("k{round}"),
DataValue::text(filler),
)
.expect("set");
}
alice.protocol.data_flush(&group, "notes").expect("flush");
assert!(
alice.protocol.data_doc_size(&group, "notes").expect("size")
> crate::protocol::data_sync::MAX_SYNC_BLOB_BYTES as u64,
"the fixture must build a document no frame can carry"
);
settle(&mut alice, &mut bob, &mut carol);
let reported: Vec<_> = alice
.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("data_doc_unsyncable"))
.collect();
assert!(
!reported.is_empty(),
"the document must actually have outgrown every frame, or this test \
asserts nothing"
);
assert!(
reported
.iter()
.all(|event| event["reason"].as_str() == Some("group_space_has_no_media_path")),
"a group dead end must name itself: {reported:?}"
);
}
fn holds(member: &mut Member, space: &str, doc: &str) -> bool {
member
.protocol
.data_list_docs(space)
.expect("list")
.iter()
.any(|name| name == doc)
}
fn group_removal_frame(from: &mut Member, group: &str, doc: &str) -> String {
from.transport.clear_sent_messages();
from.protocol.data_remove_doc(group, doc).expect("remove");
let frame = from
.transport
.sent_messages()
.iter()
.find_map(|m| {
let payload = m.content.strip_prefix(internal_prefixes::GROUP_MLS_MSG)?;
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
Some(value.get("ciphertext")?.as_str()?.to_string())
})
.expect("the removal produced a group frame");
from.transport.clear_sent_messages();
frame
}
#[test]
fn a_removal_reaches_every_member_of_a_group() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
assert!(
holds(&mut bob, &group, "notes") && holds(&mut carol, &group, "notes"),
"precondition: the document has to reach the roster before its removal can"
);
alice
.protocol
.data_remove_doc(&group, "notes")
.expect("remove");
let rounds = settle(&mut alice, &mut bob, &mut carol);
for (member, label) in [
(&mut alice, "alice"),
(&mut bob, "bob"),
(&mut carol, "carol"),
] {
assert!(
!holds(member, &group, "notes"),
"{label} still holds a document the group removed"
);
}
assert_eq!(
rounds.last(),
Some(&0),
"the exchange did not terminate: {rounds:?}"
);
}
#[test]
fn a_removal_delivered_by_the_relay_is_applied() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
assert!(holds(&mut bob, &group, "notes"), "precondition");
let ciphertext = group_removal_frame(&mut alice, &group, "notes");
bob.protocol.handle_relay_group_message_with_mls(
&group,
&alice.address,
&ciphertext,
"2026-09-22T00:00:00Z",
"relay-removal-1",
None,
None,
);
assert!(
!holds(&mut bob, &group, "notes"),
"a removal delivered by the relay was not applied"
);
assert!(
!bob.saw_group_message(),
"the removal was surfaced to the application as a chat message"
);
}
#[test]
fn a_removal_buffered_before_the_group_was_ready_is_applied_on_drain() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
assert!(holds(&mut bob, &group, "notes"), "precondition");
let ciphertext = group_removal_frame(&mut alice, &group, "notes");
bob.protocol.buffer_pending_group_message(
&group,
crate::group_mesh::PendingGroupMessage {
sender: alice.address.clone(),
message_id: "buffered-removal-1".to_string(),
logical_id: None,
ciphertext_b64: ciphertext,
timestamp: Some("2026-09-22T00:00:00Z".to_string()),
reply_to: None,
forward_info: None,
buffered_at: std::time::Instant::now(),
received_via: None,
},
);
bob.protocol.drain_pending_group_messages(&group);
assert!(
!holds(&mut bob, &group, "notes"),
"a removal drained from the pending buffer was not applied"
);
assert!(
!bob.saw_group_message(),
"the drained removal was surfaced to the application as a chat message"
);
}
#[test]
fn a_member_that_edited_while_a_removal_crossed_keeps_the_document_for_everyone() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "title", "hello");
settle(&mut alice, &mut bob, &mut carol);
alice.transport.clear_sent_messages();
bob.transport.clear_sent_messages();
carol.transport.clear_sent_messages();
alice
.protocol
.data_remove_doc(&group, "notes")
.expect("remove");
write(&mut bob, &group, "notes", "added", "B");
let rounds = settle(&mut alice, &mut bob, &mut carol);
for (member, label) in [
(&mut alice, "alice"),
(&mut bob, "bob"),
(&mut carol, "carol"),
] {
assert!(
holds(member, &group, "notes"),
"{label} lost a document an edit had kept alive"
);
assert_eq!(
read(member, &group, "notes", "added"),
Some(DataValue::text("B")),
"{label} did not converge on the edit that won"
);
assert_eq!(
read(member, &group, "notes", "title"),
Some(DataValue::text("hello")),
"{label} kept the document without the content the edit was made on"
);
}
assert_eq!(
rounds.last(),
Some(&0),
"the exchange did not terminate: {rounds:?}"
);
}
#[test]
fn a_long_run_of_directed_frames_all_arrive_and_decrypt() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "seed", "k", "v");
settle(&mut alice, &mut bob, &mut carol);
let version = alice
.protocol
.data_doc_version(&group, "seed")
.expect("version");
alice.transport.clear_sent_messages();
let total = 64usize;
for index in 0..total {
alice
.protocol
.send_group_internal_frame(
&group,
Some(&bob.address),
&format!(
"{}{{\"v\":1,\"k\":\"vv\",\"reply\":true,\"partial\":true,\"docs\":{{\"spike{index}\":\"{version}\"}}}}",
internal_prefixes::DATA_V1
),
)
.expect("send a directed frame");
}
pump(&mut alice, &mut [&mut bob, &mut carol]);
let arrived = bob
.protocol
.data_list_docs(&group)
.expect("list")
.iter()
.filter(|name| name.starts_with("spike"))
.count();
let carol_saw = carol
.protocol
.data_list_docs(&group)
.expect("list")
.iter()
.filter(|name| name.starts_with("spike"))
.count();
assert_eq!(
carol_saw, 0,
"a directed frame reached a member it was not addressed to"
);
assert!(
!bob.saw_group_message(),
"a replication frame was surfaced to the application as a chat message"
);
assert_eq!(
arrived, total,
"directed frames were lost or did not decrypt"
);
}
#[test]
fn a_run_of_directed_frames_survives_the_ratchet_promotion() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "seed", "k", "v");
settle(&mut alice, &mut bob, &mut carol);
let version = alice
.protocol
.data_doc_version(&group, "seed")
.expect("version");
spend_the_budget(&mut alice, &group);
alice.transport.clear_sent_messages();
let total = 8usize;
for index in 0..total {
alice
.protocol
.send_group_internal_frame(
&group,
Some(&bob.address),
&format!(
"{}{{\"v\":1,\"k\":\"vv\",\"reply\":true,\"partial\":true,\"docs\":{{\"promo{index}\":\"{version}\"}}}}",
internal_prefixes::DATA_V1
),
)
.expect("send a directed frame");
}
pump(&mut alice, &mut [&mut bob, &mut carol]);
let bob_saw = bob
.protocol
.data_list_docs(&group)
.expect("list")
.iter()
.filter(|name| name.starts_with("promo"))
.count();
let carol_saw = carol
.protocol
.data_list_docs(&group)
.expect("list")
.iter()
.filter(|name| name.starts_with("promo"))
.count();
assert_eq!(
bob_saw, total,
"a run of directed frames lost one at the promotion"
);
assert_eq!(
carol_saw, 1,
"the promotion did not reach the rest of the roster, or reached it more than once"
);
}
fn attachment_events(member: &Member, name: &str) -> Vec<serde_json::Value> {
member
.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 field<'a>(event: &'a serde_json::Value, key: &str) -> &'a str {
event
.get(key)
.and_then(|value| value.as_str())
.unwrap_or_else(|| panic!("event has no {key}: {event}"))
}
#[test]
fn a_member_fetches_a_blob_from_another_member() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes = vec![7u8; 40 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
let asked = attachment_events(&alice, "data_attachment_requested");
assert_eq!(asked.len(), 1, "the holder was not asked");
assert!(
attachment_events(&carol, "data_attachment_requested").is_empty(),
"a directed request reached a member it was not addressed to"
);
alice
.protocol
.data_provide_attachment(&group, &bob.address, &hash, bytes.clone())
.expect("provide");
pump(&mut alice, &mut [&mut bob, &mut carol]);
let received = attachment_events(&bob, "data_attachment_received");
assert_eq!(received.len(), 1, "the bytes never arrived");
assert_eq!(field(&received[0], "hash"), hash);
assert_eq!(
BASE64.decode(field(&received[0], "data")).expect("base64"),
bytes,
"the blob came back changed"
);
assert!(
attachment_events(&carol, "data_attachment_received").is_empty(),
"a member who asked for nothing was handed a blob"
);
assert!(
!carol.saw_group_message() && !bob.saw_group_message(),
"a chunk frame was surfaced to the application as a chat message"
);
}
#[test]
fn a_fetch_toward_a_member_known_not_to_carry_bytes_is_refused_at_the_call() {
let (mut alice, mut bob, mut carol, group) = trio();
let hash = OfflineProtocol::data_attachment_hash(b"bytes alice cannot carry");
bob.protocol.peer_data_group_blob.remove(&alice.address);
bob.protocol
.peer_data_group_blob_attested
.remove(&alice.address);
let err = bob
.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect_err("a member known not to carry bytes must be refused");
assert!(
format!("{err}").contains("does not carry attachment bytes"),
"the refusal must name the reason: {err}"
);
assert!(
bob.protocol.pending_attachment_fetches.is_empty(),
"a refused fetch still spent a slot"
);
pump(&mut bob, &mut [&mut alice, &mut carol]);
assert!(
attachment_events(&alice, "data_attachment_requested").is_empty(),
"a refused fetch still asked"
);
}
#[test]
fn a_fetch_toward_a_member_nothing_is_known_about_is_refused_at_the_call() {
let (mut alice, mut bob, mut carol, group) = trio();
let hash = OfflineProtocol::data_attachment_hash(b"bytes from a stranger");
bob.protocol.forget_data_sync_peer(&alice.address);
let err = bob
.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect_err("a member nothing is known about must be refused");
assert!(
format!("{err}").contains("shown to them as text"),
"the refusal must name the failure it prevents: {err}"
);
pump(&mut bob, &mut [&mut alice, &mut carol]);
assert!(
attachment_events(&alice, "data_attachment_requested").is_empty(),
"a refused fetch still asked"
);
}
#[test]
fn an_inviters_attestation_is_enough_to_ask_a_member() {
let (mut alice, mut bob, mut carol, group) = trio();
let hash = OfflineProtocol::data_attachment_hash(b"bytes only an inviter vouched for");
bob.protocol.forget_data_sync_peer(&alice.address);
bob.protocol
.record_attested_data(&alice.address, &[DATA_GROUP_V1, DATA_GROUP_BLOB_V1]);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("an attested member can be asked");
pump(&mut bob, &mut [&mut alice, &mut carol]);
assert_eq!(
attachment_events(&alice, "data_attachment_requested").len(),
1,
"the attested member was never asked"
);
}
#[test]
fn an_attestation_without_the_blob_entry_still_lets_the_question_go() {
let (mut alice, mut bob, mut carol, group) = trio();
let hash = OfflineProtocol::data_attachment_hash(b"bytes an old inviter said nothing about");
bob.protocol.forget_data_sync_peer(&alice.address);
bob.protocol
.record_attested_data(&alice.address, &[DATA_GROUP_V1]);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("silence about the entry is not knowledge that it is absent");
pump(&mut bob, &mut [&mut alice, &mut carol]);
assert_eq!(
attachment_events(&alice, "data_attachment_requested").len(),
1,
"a member an inviter said nothing about was never asked"
);
}
#[test]
fn chunks_nobody_asked_for_are_dropped() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes = vec![3u8; 8 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
alice
.protocol
.data_provide_attachment(&group, &bob.address, &hash, bytes)
.expect("provide");
pump(&mut alice, &mut [&mut bob, &mut carol]);
for (member, label) in [(&bob, "bob"), (&carol, "carol")] {
assert!(
attachment_events(member, "data_attachment_received").is_empty(),
"{label} assembled a blob nothing asked for"
);
assert!(
!member.saw_group_message(),
"{label} was shown a chunk frame as a chat message"
);
}
}
#[test]
fn a_blob_that_does_not_hash_to_what_was_asked_for_is_refused() {
let (mut alice, mut bob, mut carol, group) = trio();
let wanted = vec![1u8; 4 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&wanted);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
let lie = vec![2u8; 4 * 1024];
alice.protocol.send_chunk_for_test(
&group,
&SyncChannel::GroupDirected(bob.address.clone()),
&hash,
0,
1,
&BASE64.encode(&lie),
);
pump(&mut alice, &mut [&mut bob, &mut carol]);
assert!(
attachment_events(&bob, "data_attachment_received").is_empty(),
"bytes that did not hash to the request were handed to the application"
);
let ended = attachment_events(&bob, "data_attachment_unavailable");
assert_eq!(ended.len(), 1, "the fetch ended without saying so");
assert_eq!(field(&ended[0], "reason"), "hash_mismatch");
assert_eq!(
field(&ended[0], "peer_id"),
alice.address,
"the report names the group rather than the member that was asked"
);
}
#[test]
fn a_group_fetch_that_nobody_answers_names_the_member_it_was_put_to() {
let (mut alice, mut bob, mut carol, group) = trio();
let hash = OfflineProtocol::data_attachment_hash(b"bytes nobody sends");
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
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 ended = attachment_events(&bob, "data_attachment_unavailable");
assert_eq!(ended.len(), 1, "the silence was never reported");
assert_eq!(field(&ended[0], "reason"), "timeout");
assert_eq!(
field(&ended[0], "space_id"),
group,
"the report names the wrong space"
);
assert_eq!(
field(&ended[0], "peer_id"),
alice.address,
"the report names the group rather than the member that was asked"
);
}
#[test]
fn forgetting_a_member_ends_the_group_fetch_put_to_them() {
let (mut alice, mut bob, mut carol, group) = trio();
let hash = OfflineProtocol::data_attachment_hash(b"bytes alice was asked for");
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
assert!(
!bob.protocol.pending_attachment_fetches.is_empty(),
"the question was never recorded"
);
bob.protocol.forget_data_sync_peer(&alice.address);
assert!(
bob.protocol.pending_attachment_fetches.is_empty(),
"a question put to a forgotten member outlived them"
);
let ended = attachment_events(&bob, "data_attachment_unavailable");
assert_eq!(ended.len(), 1, "the fetch was dropped without saying so");
assert_eq!(field(&ended[0], "reason"), "peer_gone");
assert_eq!(
field(&ended[0], "peer_id"),
alice.address,
"the report names the group rather than the member that was asked"
);
}
#[test]
fn asking_a_second_member_is_a_new_question_rather_than_a_repeat() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes = vec![8u8; 4 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
assert_eq!(
attachment_events(&alice, "data_attachment_requested").len(),
1,
"the first member was never asked"
);
bob.protocol
.data_fetch_attachment_from(&group, &carol.address, &hash)
.expect("fallback");
pump(&mut bob, &mut [&mut alice, &mut carol]);
assert_eq!(
attachment_events(&carol, "data_attachment_requested").len(),
1,
"the fallback was swallowed as a repeat and asked nobody"
);
let ended = attachment_events(&bob, "data_attachment_unavailable");
assert_eq!(ended.len(), 1, "the displaced question never ended");
assert_eq!(field(&ended[0], "peer_id"), alice.address);
carol
.protocol
.data_provide_attachment(&group, &bob.address, &hash, bytes)
.expect("provide");
pump(&mut carol, &mut [&mut alice, &mut bob]);
let received = attachment_events(&bob, "data_attachment_received");
assert_eq!(
received.len(),
1,
"the fallback member's answer was refused"
);
assert_eq!(field(&received[0], "peer_id"), carol.address);
}
#[test]
fn a_group_fetch_cannot_be_put_to_this_device() {
let (mut alice, _bob, _carol, group) = trio();
let hash = OfflineProtocol::data_attachment_hash(b"bytes this device would already have");
let err = alice
.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect_err("a device must not ask itself");
assert!(
format!("{err}").contains("not a peer of itself"),
"the refusal must name the reason: {err}"
);
assert!(
alice.protocol.pending_attachment_fetches.is_empty(),
"a refused fetch still spent a slot"
);
}
#[test]
fn a_blob_too_large_for_a_group_is_refused_at_the_call() {
let (mut alice, mut bob, _carol, group) = trio();
let bytes = vec![9u8; (MAX_GROUP_BLOB_CHUNKS as usize + 1) * 32 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
let err = alice
.protocol
.data_provide_attachment(&group, &bob.address, &hash, bytes)
.expect_err("an oversized group attachment must refuse");
assert!(
format!("{err}").contains("1:1"),
"the refusal must say where a blob this size goes: {err}"
);
}
#[test]
fn a_member_that_has_none_says_so_rather_than_going_quiet() {
let (mut alice, mut bob, mut carol, group) = trio();
let hash = OfflineProtocol::data_attachment_hash(b"bytes alice does not have");
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
alice
.protocol
.data_decline_attachment(&group, &bob.address, &hash)
.expect("decline");
pump(&mut alice, &mut [&mut bob, &mut carol]);
let ended = attachment_events(&bob, "data_attachment_unavailable");
assert_eq!(ended.len(), 1, "a refusal left the asker waiting");
assert_eq!(field(&ended[0], "reason"), "declined");
assert_eq!(
field(&ended[0], "peer_id"),
alice.address,
"the report names the wrong member"
);
}
#[test]
fn an_answer_from_a_member_the_question_was_not_put_to_is_refused() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes = vec![5u8; 4 * 1024];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
carol
.protocol
.data_provide_attachment(&group, &bob.address, &hash, bytes)
.expect("provide");
pump(&mut carol, &mut [&mut alice, &mut bob]);
assert!(
attachment_events(&bob, "data_attachment_received").is_empty(),
"a member who was not asked answered the question anyway"
);
}
#[test]
fn a_chunk_declaring_a_shape_this_layer_does_not_carry_is_dropped() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes = vec![4u8; 1024];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
let blob = BASE64.encode(&bytes);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
for (index, total, shape) in [
(0u32, 0u32, "a count of nothing"),
(0, MAX_GROUP_BLOB_CHUNKS + 1, "a count past the cap"),
(5, 1, "an index outside its own count"),
] {
alice.protocol.send_chunk_for_test(
&group,
&SyncChannel::GroupDirected(bob.address.clone()),
&hash,
index,
total,
&blob,
);
pump(&mut alice, &mut [&mut bob, &mut carol]);
assert!(
attachment_events(&bob, "data_attachment_received").is_empty(),
"a chunk declaring {shape} was assembled anyway"
);
}
alice
.protocol
.data_provide_attachment(&group, &bob.address, &hash, bytes)
.expect("provide");
pump(&mut alice, &mut [&mut bob, &mut carol]);
assert_eq!(
attachment_events(&bob, "data_attachment_received").len(),
1,
"a malformed chunk cancelled the fetch it arrived against"
);
}
#[test]
fn a_chunk_over_the_frame_budget_is_dropped() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes = vec![2u8; 1024];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
let oversized = BASE64.encode(vec![0u8; MAX_SYNC_BLOB_BYTES + 1]);
alice.protocol.send_chunk_for_test(
&group,
&SyncChannel::GroupDirected(bob.address.clone()),
&hash,
0,
1,
&oversized,
);
pump(&mut alice, &mut [&mut bob, &mut carol]);
assert!(
attachment_events(&bob, "data_attachment_received").is_empty(),
"a chunk over the frame budget was assembled anyway"
);
alice
.protocol
.data_provide_attachment(&group, &bob.address, &hash, bytes)
.expect("provide");
pump(&mut alice, &mut [&mut bob, &mut carol]);
assert_eq!(
attachment_events(&bob, "data_attachment_received").len(),
1,
"an oversized chunk cancelled the fetch it arrived against"
);
}
#[test]
fn a_carriage_that_stops_half_way_still_ends() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes: Vec<u8> = (0..(MAX_SYNC_BLOB_BYTES + 512)).map(|i| i as u8).collect();
let hash = OfflineProtocol::data_attachment_hash(&bytes);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
let first = BASE64.encode(&bytes[..MAX_SYNC_BLOB_BYTES]);
alice.protocol.send_chunk_for_test(
&group,
&SyncChannel::GroupDirected(bob.address.clone()),
&hash,
0,
2,
&first,
);
pump(&mut alice, &mut [&mut bob, &mut carol]);
assert!(
attachment_events(&bob, "data_attachment_received").is_empty(),
"an incomplete answer was handed over"
);
assert_eq!(
bob.protocol.pending_attachment_fetches.len(),
1,
"the half-arrived answer left no question behind"
);
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();
assert!(
bob.protocol.pending_attachment_fetches.is_empty(),
"a half-arrived answer held its slot and its bytes for good"
);
let ended = attachment_events(&bob, "data_attachment_unavailable");
assert_eq!(ended.len(), 1, "the stalled carriage was never reported");
assert_eq!(field(&ended[0], "reason"), "timeout");
assert_eq!(field(&ended[0], "peer_id"), alice.address);
}
#[test]
fn a_duplicate_chunk_replaces_rather_than_appends() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes: Vec<u8> = (0..(MAX_SYNC_BLOB_BYTES + 512)).map(|i| i as u8).collect();
let hash = OfflineProtocol::data_attachment_hash(&bytes);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
let pieces: Vec<String> = bytes
.chunks(MAX_SYNC_BLOB_BYTES)
.map(|chunk| BASE64.encode(chunk))
.collect();
assert_eq!(pieces.len(), 2, "this test needs a blob of two pieces");
let total = pieces.len() as u32;
for index in [0usize, 0, 1] {
alice.protocol.send_chunk_for_test(
&group,
&SyncChannel::GroupDirected(bob.address.clone()),
&hash,
index as u32,
total,
&pieces[index],
);
}
pump(&mut alice, &mut [&mut bob, &mut carol]);
let received = attachment_events(&bob, "data_attachment_received");
assert_eq!(received.len(), 1, "the repeated piece broke the count");
assert_eq!(
BASE64.decode(field(&received[0], "data")).expect("base64"),
bytes,
"a duplicate piece was appended rather than replacing"
);
}
#[test]
fn a_refusal_from_a_member_the_question_was_not_put_to_is_ignored() {
let (mut alice, mut bob, mut carol, group) = trio();
let bytes = vec![6u8; 2048];
let hash = OfflineProtocol::data_attachment_hash(&bytes);
bob.protocol
.data_fetch_attachment_from(&group, &alice.address, &hash)
.expect("fetch");
pump(&mut bob, &mut [&mut alice, &mut carol]);
carol
.protocol
.data_decline_attachment(&group, &bob.address, &hash)
.expect("decline");
pump(&mut carol, &mut [&mut alice, &mut bob]);
assert!(
attachment_events(&bob, "data_attachment_unavailable").is_empty(),
"a member who was not asked ended somebody else's fetch"
);
alice
.protocol
.data_provide_attachment(&group, &bob.address, &hash, bytes)
.expect("provide");
pump(&mut alice, &mut [&mut bob, &mut carol]);
assert_eq!(
attachment_events(&bob, "data_attachment_received").len(),
1,
"the fetch did not survive a refusal from the wrong member"
);
}
fn let_commits_go_quiet(member: &mut Member) {
let now = std::time::Instant::now();
for (due, _) in member.protocol.data_sync_settle.values_mut() {
*due = now;
}
}
fn group_frames_sent(member: &Member) -> usize {
member
.transport
.sent_messages()
.iter()
.filter(|message| {
message
.content
.starts_with(internal_prefixes::GROUP_MLS_MSG)
})
.count()
}
#[test]
fn a_lost_final_delta_heals_once_local_commits_go_quiet() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "a", "1");
write(&mut alice, &group, "notes", "b", "2");
settle(&mut alice, &mut bob, &mut carol);
write(&mut alice, &group, "notes", "c", "3");
alice.transport.clear_sent_messages();
settle(&mut alice, &mut bob, &mut carol);
alice.protocol.process().expect("process");
assert_eq!(
group_frames_sent(&alice),
0,
"a settle offer went out before local commits had gone quiet"
);
for member in [&mut bob, &mut carol] {
assert_eq!(read(member, &group, "notes", "c"), None);
}
let_commits_go_quiet(&mut alice);
alice.protocol.process().expect("process");
let rounds = settle(&mut alice, &mut bob, &mut carol);
for (label, member) in [("bob", &mut bob), ("carol", &mut carol)] {
assert_eq!(
read(member, &group, "notes", "c"),
Some(DataValue::text("3")),
"{label} never recovered the lost tail of the burst"
);
}
assert_eq!(
rounds.last(),
Some(&0),
"the settle exchange has to stop (rounds: {rounds:?})"
);
}
#[test]
fn a_settle_offer_does_not_cascade_into_further_offers() {
let (mut alice, mut bob, mut carol, group) = trio();
write(&mut alice, &group, "notes", "a", "1");
settle(&mut alice, &mut bob, &mut carol);
write(&mut alice, &group, "notes", "b", "2");
alice.transport.clear_sent_messages();
let_commits_go_quiet(&mut alice);
alice.protocol.process().expect("process");
let mut passes = Vec::new();
for _ in 0..6 {
let carried = pass_group_frames(&mut alice, &mut bob, &mut carol);
passes.push(carried);
if carried == 0 {
break;
}
}
assert_eq!(
passes.last(),
Some(&0),
"the settle exchange never went quiet (group frames per pass: {passes:?})"
);
let total: usize = passes.iter().sum();
assert!(
total <= 2 + 2 * 2 + 2 * 2,
"a settle offer cost more than offer, reply and catch-up can \
(group frames per pass: {passes:?})"
);
assert_eq!(
read(&mut bob, &group, "notes", "b"),
Some(DataValue::text("2"))
);
for (label, member) in [("alice", &alice), ("bob", &bob), ("carol", &carol)] {
assert!(
member.protocol.data_sync_settle.is_empty(),
"{label} armed a settle offer without committing anything locally"
);
}
alice.protocol.process().expect("process");
assert_eq!(group_frames_sent(&alice), 0, "a settle offer fired twice");
}
#[test]
fn a_burst_of_commits_is_settled_by_one_offer() {
let (mut alice, mut bob, mut carol, group) = trio();
for i in 0..5 {
write(&mut alice, &group, "notes", &format!("k{i}"), "v");
}
write(&mut alice, &group, "todo", "k", "v");
settle(&mut alice, &mut bob, &mut carol);
let_commits_go_quiet(&mut alice);
alice.protocol.process().expect("process");
assert_eq!(
data_frames_sent(&alice, &mut bob, &group),
1,
"six commits across two documents must settle with one offer"
);
assert_eq!(data_frames_sent(&alice, &mut carol, &group), 1);
}
fn pass_group_frames(alice: &mut Member, bob: &mut Member, carol: &mut Member) -> usize {
let mut carried = group_frames_sent(alice);
{
let (b, c) = (&mut *bob, &mut *carol);
pump(alice, &mut [b, c]);
}
carried += group_frames_sent(bob);
{
let (a, c) = (&mut *alice, &mut *carol);
pump(bob, &mut [a, c]);
}
carried += group_frames_sent(carol);
{
let (a, b) = (&mut *alice, &mut *bob);
pump(carol, &mut [a, b]);
}
carried
}
#[test]
fn only_group_frames_the_sdk_sends_itself_are_marked_no_alert() {
let (mut alice, _bob, _carol, group) = trio();
let no_alert = |m: &offline_protocol_core::Message| {
m.metadata.get(crate::constants::NO_ALERT_KEY).cloned()
};
write(&mut alice, &group, "notes", "title", "hello");
let replication = alice.transport.sent_messages();
alice.transport.clear_sent_messages();
assert_eq!(replication.len(), 2);
assert!(replication
.iter()
.all(|m| no_alert(m).as_deref() == Some("1")));
alice
.protocol
.send_group_message(&group, "hi all", None, None)
.unwrap();
let chat = alice.transport.sent_messages();
alice.transport.clear_sent_messages();
assert_eq!(chat.len(), 2);
assert!(chat.iter().all(|m| no_alert(m).is_none()));
let dave = id("dave");
let kp = crate::test_identity::manager_for("dave", Arc::new(InMemoryStorage::new()))
.get_or_create_key_package()
.unwrap();
alice.protocol.pending_key_packages.insert(
dave.clone(),
crate::protocol::types::ReceivedKeyPackage {
key_package_data: kp.key_package_data,
local_expires_at_ms: chrono::Utc::now().timestamp_millis() as u64 + 60_000,
},
);
alice.protocol.invite_to_group(&group, &dave).unwrap();
let invite = alice.transport.sent_messages();
let welcome = invite
.iter()
.find(|m| m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME))
.expect("a group Welcome to the invitee");
assert_eq!(welcome.recipient.as_str(), dave);
assert_eq!(no_alert(welcome), None);
let commits: Vec<_> = invite
.iter()
.filter(|m| m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT))
.collect();
assert!(!commits.is_empty());
assert!(commits.iter().all(|m| no_alert(m).as_deref() == Some("1")));
}