use super::group_mesh::*;
use crate::protocol::tests::{create_test_config, create_test_config_for_user};
use crate::protocol::DATA_GROUP_V1;
use crate::protocol::{base64_decode, base64_encode, internal_prefixes, InternalMessageResult};
use crate::test_identity::{id, session_slot};
use crate::{Event, OfflineProtocol};
use offline_protocol_core::{AppId, UserId};
use offline_protocol_mls::GroupRole;
use offline_protocol_transport::{Transport, TransportType};
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::{Duration as StdDuration, Instant};
fn setup_started_with_events() -> (OfflineProtocol, Arc<Mutex<Vec<Event>>>) {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
(protocol, events)
}
fn setup_with_events() -> (OfflineProtocol, Arc<Mutex<Vec<Event>>>) {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
(protocol, events)
}
fn setup_alice_bob_group(group_name: &str) -> (OfflineProtocol, OfflineProtocol, String) {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group(group_name).unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let (welcome, _commit) = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap()
};
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.join_group(&welcome).unwrap();
}
alice.refresh_group_members(&group_id).unwrap();
bob.group_mesh
.members
.insert(group_id.clone(), vec![id("alice"), id("bob")]);
(alice, bob, group_id)
}
fn make_message(sender: &str, recipient: &str, content: &str) -> offline_protocol_core::Message {
let mut message = make_unsigned_message(sender, recipient, content);
if crate::OfflineProtocol::is_internal_prefix(content) {
crate::test_identity::try_sign_as_sender(&mut message);
}
message
}
fn make_unsigned_message(
sender: &str,
recipient: &str,
content: &str,
) -> offline_protocol_core::Message {
offline_protocol_core::Message::new(
UserId::new(sender).unwrap(),
UserId::new(recipient).unwrap(),
AppId::new("test-app").unwrap(),
content,
)
}
#[test]
fn test_group_welcome_cannot_squat_session_slot() {
let storage_m = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut mallory = OfflineProtocol::new(create_test_config_for_user("mallory")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
mallory.initialize_mls_for_test(storage_m).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
mallory.start().unwrap();
bob.start().unwrap();
let group_info = mallory.create_group("mallory-group").unwrap();
let gid = offline_protocol_mls::GroupId::new(group_info.group_id.as_str()).unwrap();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let (welcome, _commit) = {
let mallory_mls = mallory.mls_manager_for_testing().read().unwrap();
mallory_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap()
};
let squat = GroupMlsWelcomePayload {
member_rich: HashMap::new(),
member_data: HashMap::new(),
created_by: None,
group_id: session_slot("alice", "bob"),
group_name: None,
welcome_data: base64_encode(&welcome.welcome_data),
member_list: vec![id("mallory"), id("bob")],
member_roles: HashMap::new(),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&squat).unwrap()
);
bob.process_internal_message(&make_message(&id("mallory"), &id("bob"), &content));
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
assert!(
!bob_mls.has_session(&id("alice")).unwrap(),
"a group Welcome must not install into the reserved `session:` slot"
);
}
#[test]
fn test_group_mls_create_mesh_group_requires_mls() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let result = protocol.create_group("Test Group");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("MLS not initialized"),
"Expected MLS not initialized error"
);
}
#[test]
fn test_group_mls_create_mesh_group_with_mls() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let group_info = protocol.create_group("Test Group").unwrap();
assert_eq!(group_info.name, Some("Test Group".to_string()));
assert!(group_info.group_id.as_str().starts_with("group:"));
assert!(group_info.members.contains(&id("user123")));
let cached = protocol
.group_mesh
.members
.get(group_info.group_id.as_str());
assert!(cached.is_some());
assert!(cached.unwrap().contains(&id("user123")));
}
#[test]
fn test_group_mls_list_groups() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let groups = protocol.list_groups().unwrap();
assert!(groups.is_empty());
let info = protocol.create_group("My Group").unwrap();
let groups = protocol.list_groups().unwrap();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0], info.group_id.as_str());
}
#[test]
fn test_group_mls_send_message_requires_mls() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let result = protocol.send_group_message("some-group", "hello", None, None);
assert!(result.is_err());
}
#[test]
fn test_group_mls_send_message_group_not_found() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let result = protocol.send_group_message("nonexistent-group", "hello", None, None);
assert!(result.is_err());
}
#[test]
fn test_group_mls_send_message_solo_group() {
let (mut protocol, _events) = setup_started_with_events();
let info = protocol.create_group("Solo Group").unwrap();
let group_id = info.group_id.as_str().to_string();
let result = protocol.send_group_message(&group_id, "hello", None, None);
assert!(result.is_ok());
let message_ids = result.unwrap();
assert!(message_ids.is_empty(), "No messages should be sent to self");
}
#[test]
fn test_group_mls_leave_group() {
let (mut protocol, _events) = setup_started_with_events();
let info = protocol.create_group("Leave Test").unwrap();
let group_id = info.group_id.as_str().to_string();
assert!(protocol.group_mesh.members.contains_key(&group_id));
protocol.leave_group(&group_id).unwrap();
assert!(!protocol.group_mesh.members.contains_key(&group_id));
}
#[test]
fn test_group_mls_invite_requires_key_package() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let info = protocol.create_group("Invite Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let result = protocol.invite_to_group(&group_id, &id("bob"));
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("No key package"),
"Expected no key package error"
);
}
#[test]
fn test_group_mls_invite_rejects_invalid_user_before_side_effects() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Validation Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let before = protocol.get_group_info(&group_id).unwrap().unwrap();
assert!(protocol.pending_key_packages.is_empty());
events.lock().unwrap().clear();
let result = protocol.invite_to_group(&group_id, "unresolved:token");
assert!(matches!(result, Err(crate::Error::InvalidArgument(_))));
let after = protocol.get_group_info(&group_id).unwrap().unwrap();
assert_eq!(after.epoch, before.epoch);
assert_eq!(after.members, before.members);
assert_eq!(after.members_count, before.members_count);
assert!(protocol.pending_key_packages.is_empty());
assert!(events.lock().unwrap().is_empty());
}
#[test]
fn test_group_mls_dedup_cache() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let key = "msg-001".to_string();
protocol
.group_mesh
.message_dedup
.insert(key.clone(), Instant::now());
assert!(protocol.group_mesh.message_dedup.contains_key(&key));
protocol.cleanup_group_message_dedup();
assert!(protocol.group_mesh.message_dedup.contains_key(&key));
let old_key = "msg-002".to_string();
protocol.group_mesh.message_dedup.insert(
old_key.clone(),
Instant::now() - StdDuration::from_secs(GROUP_MESSAGE_DEDUP_TTL_SECS + 1),
);
protocol.cleanup_group_message_dedup();
assert!(!protocol.group_mesh.message_dedup.contains_key(&old_key));
assert!(protocol.group_mesh.message_dedup.contains_key(&key));
}
#[test]
fn test_group_mls_process_leave_message() {
let (mut protocol, events) = setup_started_with_events();
protocol.group_mesh.members.insert(
"group:test-123".to_string(),
vec![id("user123"), id("alice"), id("bob")],
);
let leave_payload = GroupMlsLeavePayload {
group_id: "group:test-123".to_string(),
leaving_member: id("alice"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_LEAVE,
serde_json::to_string(&leave_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let leave_event = events
.iter()
.find(|e| matches!(e, Event::GroupMemberRemoved { .. }));
assert!(
leave_event.is_none(),
"Non-elected node should not emit premature GroupMemberRemoved"
);
}
#[test]
fn test_group_mls_process_commit_empty_ciphertext_no_event() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Commit Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: String::new(),
epoch: 1,
affected_member: Some(id("carol")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let add_event = events.iter().find(|e| {
matches!(e, Event::GroupMemberAdded { group_id: gid, user_id, .. }
if gid == &group_id && user_id == &id("carol"))
});
assert!(
add_event.is_none(),
"Should NOT emit GroupMemberAdded when MLS commit processing fails"
);
}
#[test]
fn test_group_mls_refresh_group_members() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let info = protocol.create_group("Refresh Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.clear();
let members = protocol.refresh_group_members(&group_id).unwrap();
assert!(members.contains(&id("user123")));
assert!(protocol.group_mesh.members.contains_key(&group_id));
}
#[test]
fn test_group_mls_group_events_emitted_on_create() {
let (mut protocol, events) = setup_with_events();
protocol.create_group("Event Test").unwrap();
let events = events.lock().unwrap();
let created_event = events
.iter()
.find(|e| matches!(e, Event::GroupCreated { name, .. } if name == "Event Test"));
assert!(created_event.is_some(), "Expected GroupCreated event");
}
#[test]
fn test_group_mls_cleanup_includes_group_dedup() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.group_mesh.message_dedup.insert(
"msg-expired-001".to_string(),
Instant::now() - StdDuration::from_secs(GROUP_MESSAGE_DEDUP_TTL_SECS + 1),
);
assert_eq!(protocol.group_mesh.message_dedup.len(), 1);
protocol.cleanup_expired_entries();
assert!(protocol.group_mesh.message_dedup.is_empty());
}
#[test]
fn test_group_mls_internal_prefixes_defined() {
assert!(internal_prefixes::GROUP_MLS_MSG.starts_with("__"));
assert!(internal_prefixes::GROUP_MLS_MSG.ends_with("__"));
assert!(internal_prefixes::GROUP_MLS_WELCOME.starts_with("__"));
assert!(internal_prefixes::GROUP_MLS_COMMIT.starts_with("__"));
assert!(internal_prefixes::GROUP_MLS_LEAVE.starts_with("__"));
let prefixes = [
internal_prefixes::GROUP_MLS_MSG,
internal_prefixes::GROUP_MLS_WELCOME,
internal_prefixes::GROUP_MLS_COMMIT,
internal_prefixes::GROUP_MLS_LEAVE,
];
for (i, a) in prefixes.iter().enumerate() {
for (j, b) in prefixes.iter().enumerate() {
if i != j {
assert_ne!(a, b, "Prefixes must be unique");
assert!(!a.starts_with(b), "No prefix should be a prefix of another");
}
}
}
}
#[test]
fn test_group_mls_payload_serialization_roundtrip() {
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: "group:abc".to_string(),
ciphertext: "dGVzdA==".to_string(),
epoch: 42,
reply_to: Some("msg-123".to_string()),
forward_info: None,
};
let json = serde_json::to_string(&msg_payload).unwrap();
let parsed: GroupMlsMessagePayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.group_id, "group:abc");
assert_eq!(parsed.ciphertext, "dGVzdA==");
assert_eq!(parsed.epoch, 42);
assert_eq!(parsed.reply_to, Some("msg-123".to_string()));
let msg_no_reply = GroupMlsMessagePayload {
message_id: None,
group_id: "group:abc".to_string(),
ciphertext: "dGVzdA==".to_string(),
epoch: 1,
reply_to: None,
forward_info: None,
};
let json_no_reply = serde_json::to_string(&msg_no_reply).unwrap();
assert!(!json_no_reply.contains("reply_to"));
let parsed_no_reply: GroupMlsMessagePayload = serde_json::from_str(&json_no_reply).unwrap();
assert_eq!(parsed_no_reply.reply_to, None);
assert_eq!(parsed_no_reply.epoch, 1);
let welcome_payload = GroupMlsWelcomePayload {
member_rich: HashMap::new(),
member_data: HashMap::new(),
created_by: None,
group_id: "group:def".to_string(),
group_name: Some("Test Group".to_string()),
welcome_data: "d2VsY29tZQ==".to_string(),
member_list: vec!["alice".to_string(), "bob".to_string()],
member_roles: HashMap::new(),
};
let json = serde_json::to_string(&welcome_payload).unwrap();
let parsed: GroupMlsWelcomePayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.group_id, "group:def");
assert_eq!(parsed.group_name, Some("Test Group".to_string()));
assert_eq!(parsed.member_list.len(), 2);
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: "group:ghi".to_string(),
commit_type: GroupCommitType::Add,
ciphertext: "Y29tbWl0".to_string(),
epoch: 5,
affected_member: Some("carol".to_string()),
role: None,
};
let json = serde_json::to_string(&commit_payload).unwrap();
let parsed: GroupMlsCommitPayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.commit_type, GroupCommitType::Add);
assert_eq!(parsed.affected_member, Some("carol".to_string()));
assert_eq!(parsed.epoch, 5);
assert!(json.contains("\"add\""));
let leave_payload = GroupMlsLeavePayload {
group_id: "group:jkl".to_string(),
leaving_member: "dave".to_string(),
};
let json = serde_json::to_string(&leave_payload).unwrap();
let parsed: GroupMlsLeavePayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.leaving_member, "dave".to_string());
}
#[test]
fn test_group_mls_leave_sender_mismatch_rejected() {
let (mut protocol, events) = setup_started_with_events();
let leave_payload = GroupMlsLeavePayload {
group_id: "group:test-123".to_string(),
leaving_member: id("alice"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_LEAVE,
serde_json::to_string(&leave_payload).unwrap()
);
let message = make_message(&id("bob"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let leave_event = events
.iter()
.find(|e| matches!(e, Event::GroupMemberRemoved { .. }));
assert!(leave_event.is_none(), "Spoofed leave should not emit event");
}
#[test]
fn test_group_mls_full_lifecycle_create_invite_send_decrypt() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Integration Test Group");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let encrypted = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls.encrypt_for_group(&gid, b"Hello group!").unwrap()
};
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: group_id.clone(),
ciphertext: base64_encode(&encrypted.ciphertext),
epoch: encrypted.epoch,
reply_to: None,
forward_info: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload).unwrap()
);
let bob_message = make_message(&id("alice"), &id("bob"), &content);
let result = bob.process_internal_message(&bob_message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = bob_events.lock().unwrap();
let received = events.iter().find(|e| {
matches!(e, Event::GroupMessageReceived { group_id: gid, content, .. }
if gid == &group_id && content == "Hello group!")
});
assert!(
received.is_some(),
"Bob should have received 'Hello group!' via GroupMessageReceived event"
);
}
#[test]
fn test_group_mls_message_with_spoofed_sender_not_surfaced() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Spoof Test Group");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let encrypted = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.encrypt_for_group(&gid, b"Forged attribution")
.unwrap()
};
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: group_id.clone(),
ciphertext: base64_encode(&encrypted.ciphertext),
epoch: encrypted.epoch,
reply_to: None,
forward_info: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload).unwrap()
);
let bob_message = make_message(&id("carol"), &id("bob"), &content);
let result = bob.process_internal_message(&bob_message);
assert!(
matches!(result, Some(InternalMessageResult::SecurityRejected)),
"a spoofed group message must be security-rejected (no delivery ACK)"
);
let events = bob_events.lock().unwrap();
let received = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }));
assert!(
received.is_none(),
"a group message whose MLS-authenticated sender does not match the wire sender must not be surfaced"
);
assert!(
bob.group_mesh.pending_group_messages.is_empty(),
"a security-rejected message is a permanent failure and must not be buffered for retry"
);
}
#[test]
fn test_group_mls_commit_with_spoofed_sender_rejected_not_buffered() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Spoof Commit Group");
let commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls.update_keys(&gid).unwrap()
};
let epoch_before = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.get_group_info(&gid).unwrap().unwrap().epoch
};
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::KeyUpdate,
ciphertext: base64_encode(&commit.ciphertext),
epoch: commit.epoch,
affected_member: None,
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let bob_message = make_message(&id("carol"), &id("bob"), &content);
bob.process_internal_message(&bob_message);
let epoch_after = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.get_group_info(&gid).unwrap().unwrap().epoch
};
assert_eq!(
epoch_before, epoch_after,
"spoofed commit must not advance group state"
);
assert!(
!bob.group_mesh.pending_commits.contains_key(&group_id),
"spoofed commit is a permanent failure and must not be buffered for retry"
);
}
#[test]
fn test_group_mls_send_message_multiple_members() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Multi-Member Group").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![
id("user123"), id("bob"),
id("carol"),
],
);
let msg_ids = protocol
.send_group_message(&group_id, "hello everyone", None, None)
.unwrap();
assert_eq!(msg_ids.len(), 2, "Should send to 2 members (bob, carol)");
let events = events.lock().unwrap();
let sent_event = events.iter().find(|e| {
matches!(e, Event::GroupMessageSent { group_id: gid, member_count, .. }
if gid == &group_id && *member_count == 2)
});
assert!(
sent_event.is_some(),
"Expected GroupMessageSent event with member_count=2"
);
}
#[test]
fn test_group_mls_dedup_cap_enforcement() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let count = MAX_GROUP_MESSAGE_DEDUP_ENTRIES + 100;
for i in 0..count {
protocol
.group_mesh
.message_dedup
.insert(format!("msg-{:06}", i), Instant::now());
}
assert_eq!(protocol.group_mesh.message_dedup.len(), count);
protocol.cleanup_group_message_dedup();
assert!(
protocol.group_mesh.message_dedup.len() <= MAX_GROUP_MESSAGE_DEDUP_ENTRIES,
"Dedup cache should be capped at {}",
MAX_GROUP_MESSAGE_DEDUP_ENTRIES
);
}
#[test]
fn test_group_mls_commit_unknown_group() {
let (mut protocol, events) = setup_started_with_events();
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: "group:nonexistent".to_string(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"fake-commit-data"),
epoch: 1,
affected_member: Some(id("carol")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
assert!(
events
.iter()
.all(|e| !matches!(e, Event::GroupMemberAdded { .. })),
"No GroupMemberAdded event for unknown group"
);
}
#[test]
fn test_group_mls_message_after_leaving() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Leave Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.leave_group(&group_id).unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: group_id.clone(),
ciphertext: base64_encode(b"encrypted-content"),
epoch: 1,
reply_to: None,
forward_info: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
assert!(
events
.iter()
.all(|e| !matches!(e, Event::GroupMessageReceived { .. })),
"No message event after leaving group"
);
}
#[test]
fn test_group_mls_oversized_payload_rejected() {
let (mut protocol, events) = setup_started_with_events();
let oversized = "A".repeat(MAX_BASE64_PAYLOAD_SIZE + 1);
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: "group:test".to_string(),
ciphertext: oversized,
epoch: 1,
reply_to: None,
forward_info: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
assert!(
events
.iter()
.all(|e| !matches!(e, Event::GroupMessageReceived { .. })),
"Oversized payload should not produce a message event"
);
}
#[test]
fn test_group_mls_duplicate_message_rejected() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Dedup Test Group");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let encrypted = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls.encrypt_for_group(&gid, b"Hello dedup!").unwrap()
};
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: group_id.clone(),
ciphertext: base64_encode(&encrypted.ciphertext),
epoch: encrypted.epoch,
reply_to: None,
forward_info: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload).unwrap()
);
let bob_message = make_message(&id("alice"), &id("bob"), &content);
let result1 = bob.process_internal_message(&bob_message);
assert!(matches!(result1, Some(InternalMessageResult::Consumed)));
let result2 = bob.process_internal_message(&bob_message);
assert!(matches!(result2, Some(InternalMessageResult::Consumed)));
let events = bob_events.lock().unwrap();
let received_count = events
.iter()
.filter(|e| matches!(e, Event::GroupMessageReceived { .. }))
.count();
assert_eq!(
received_count, 1,
"Expected exactly 1 GroupMessageReceived, got {} (duplicate should be dropped)",
received_count
);
}
#[test]
fn test_group_mls_leave_non_member_rejected() {
let (mut protocol, events) = setup_started_with_events();
protocol.group_mesh.members.insert(
"group:nonmember-test".to_string(),
vec![id("user123"), id("alice")],
);
let leave_payload = GroupMlsLeavePayload {
group_id: "group:nonmember-test".to_string(),
leaving_member: id("eve"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_LEAVE,
serde_json::to_string(&leave_payload).unwrap()
);
let message = make_message(&id("eve"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
assert!(
events
.iter()
.all(|e| !matches!(e, Event::GroupMemberRemoved { .. })),
"Non-member leave should not produce a removal event"
);
}
#[test]
fn test_group_mls_leave_deterministic_election() {
let (mut protocol, events) = setup_started_with_events();
let group_id = "group:election-test".to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("alice"), id("bob"), id("user123")],
);
let leave_payload = GroupMlsLeavePayload {
group_id: group_id.clone(),
leaving_member: id("bob"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_LEAVE,
serde_json::to_string(&leave_payload).unwrap()
);
let message = make_message(&id("bob"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let remove_event = events
.iter()
.find(|e| matches!(e, Event::GroupMemberRemoved { .. }));
assert!(
remove_event.is_none(),
"Non-elected node should not emit premature GroupMemberRemoved"
);
}
#[test]
fn test_group_mls_leave_we_are_elected() {
let (mut protocol, events) = setup_started_with_events();
let group_id = "group:elected-test".to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123"), "zzz".to_string()]);
let leave_payload = GroupMlsLeavePayload {
group_id: group_id.clone(),
leaving_member: id("zzz"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_LEAVE,
serde_json::to_string(&leave_payload).unwrap()
);
let message = make_message(&id("zzz"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let remove_count = events
.iter()
.filter(|e| matches!(e, Event::GroupMemberRemoved { user_id, .. } if user_id == "zzz"))
.count();
assert!(
remove_count <= 1,
"Expected at most 1 GroupMemberRemoved event, got {}",
remove_count
);
}
#[test]
fn test_group_mls_remove_from_group() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Remove Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let result = protocol.remove_from_group(&group_id, "nonexistent-user");
assert!(result.is_err(), "Removing non-member should fail");
}
#[test]
fn test_group_mls_base64_decode_size_guard() {
let oversized = "A".repeat(MAX_BASE64_PAYLOAD_SIZE + 1);
let result = base64_decode(&oversized);
assert!(result.is_err());
assert!(result.unwrap_err().contains("payload too large"));
let normal = base64_encode(b"hello world");
let result = base64_decode(&normal);
assert!(result.is_ok());
assert_eq!(result.unwrap(), b"hello world");
}
#[test]
fn test_group_mls_welcome_bad_data_no_panic() {
let (mut protocol, events) = setup_started_with_events();
let welcome_payload = GroupMlsWelcomePayload {
member_rich: HashMap::new(),
member_data: HashMap::new(),
created_by: None,
group_id: "group:bad-welcome".to_string(),
group_name: Some("Bad Group".to_string()),
welcome_data: "not-valid-base64!!!".to_string(),
member_list: vec![id("alice")],
member_roles: HashMap::new(),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&welcome_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
assert!(
events
.iter()
.all(|e| !matches!(e, Event::GroupMemberAdded { .. })),
"Bad welcome data should not produce a join event"
);
}
#[test]
fn test_group_mls_welcome_valid_base64_bad_mls_no_panic() {
let (mut protocol, events) = setup_started_with_events();
let welcome_payload = GroupMlsWelcomePayload {
member_rich: HashMap::new(),
member_data: HashMap::new(),
created_by: None,
group_id: "group:garbage-mls".to_string(),
group_name: Some("Garbage MLS".to_string()),
welcome_data: base64_encode(b"this is not valid MLS data"),
member_list: vec![id("alice")],
member_roles: HashMap::new(),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&welcome_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
assert!(
events
.iter()
.all(|e| !matches!(e, Event::GroupMemberAdded { .. })),
"Invalid MLS welcome should not produce a join event"
);
}
#[test]
fn test_group_mls_send_message_partial_failure() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let info = protocol.create_group("Partial Failure Group").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol")],
);
let result = protocol.send_group_message(&group_id, "hello", None, None);
assert!(result.is_err(), "Total send failure should return Err");
assert!(
result
.unwrap_err()
.to_string()
.contains("All group message sends failed"),
"Error should indicate total failure"
);
}
#[test]
fn test_group_mls_commit_oversized_ciphertext_rejected() {
let (mut protocol, events) = setup_started_with_events();
let oversized = "A".repeat(MAX_BASE64_PAYLOAD_SIZE + 1);
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: "group:oversized".to_string(),
commit_type: GroupCommitType::Add,
ciphertext: oversized,
epoch: 1,
affected_member: Some(id("carol")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
assert!(
events.iter().all(|e| !matches!(
e,
Event::GroupMemberAdded { .. } | Event::GroupMemberRemoved { .. }
)),
"Oversized commit should not produce membership events"
);
}
#[test]
fn test_group_mls_malformed_json_payloads() {
let (mut protocol, _) = setup_started_with_events();
let prefixes = [
internal_prefixes::GROUP_MLS_MSG,
internal_prefixes::GROUP_MLS_WELCOME,
internal_prefixes::GROUP_MLS_COMMIT,
internal_prefixes::GROUP_MLS_LEAVE,
];
for prefix in &prefixes {
let content = format!("{}{{not valid json!", prefix);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(
matches!(result, Some(InternalMessageResult::Consumed)),
"Malformed JSON for prefix {} should be consumed",
prefix
);
}
}
#[test]
fn test_group_mls_dedup_inserted_before_decrypt_attempt() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Dedup Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: group_id.clone(),
ciphertext: base64_encode(b"definitely-not-valid-mls-ciphertext"),
epoch: 1,
reply_to: None,
forward_info: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let msg_id = message.id.as_str().to_string();
let _ = protocol.process_internal_message(&message);
assert!(
protocol.group_mesh.message_dedup.contains_key(&msg_id),
"Dedup entry must be inserted even when decryption fails"
);
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let _ = protocol.process_internal_message(&message);
let events = events.lock().unwrap();
assert!(
events
.iter()
.all(|e| !matches!(e, Event::GroupMessageReceived { .. })),
"Replayed message after dedup must not produce any event"
);
}
#[test]
fn test_group_mls_leave_preserves_state_on_total_send_failure() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let info = protocol.create_group("Leave Fail Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.get_mut(&group_id)
.unwrap()
.push(id("bob"));
{
let mls = protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.set_member_role(&gid, &id("bob"), GroupRole::Admin)
.unwrap();
}
let result = protocol.leave_group(&group_id);
assert!(
result.is_err(),
"Leave should fail when all notifications fail"
);
assert!(
result
.unwrap_err()
.to_string()
.contains("local state preserved"),
"Error should indicate state was preserved for retry"
);
let groups = protocol.list_groups().unwrap();
assert!(
groups.contains(&group_id),
"Group should still exist locally after failed leave"
);
assert!(
protocol.group_mesh.members.contains_key(&group_id),
"Group member cache should be preserved after failed leave"
);
}
#[test]
fn test_group_mls_leave_deletes_state_after_successful_notification() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Leave OK Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let result = protocol.leave_group(&group_id);
assert!(result.is_ok(), "Leave with no other members should succeed");
let groups = protocol.list_groups().unwrap();
assert!(
!groups.contains(&group_id),
"Group should be removed after successful leave"
);
assert!(
!protocol.group_mesh.members.contains_key(&group_id),
"Cache should be cleared after successful leave"
);
}
#[test]
fn test_group_mls_commit_failure_buffers_for_retry() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let group_id = "group:buffer-test".to_string();
let pending = PendingCommit {
sender: "alice".to_string(),
message_id: "test-mid-1".to_string(),
data: serde_json::to_string(&GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"commit-data"),
epoch: 99,
affected_member: Some("new-member".to_string()),
role: None,
})
.unwrap(),
buffered_at: Instant::now(),
retry_count: 0,
};
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(pending);
assert!(
protocol.group_mesh.pending_commits.contains_key(&group_id),
"Failed commit should be buffered"
);
assert_eq!(
protocol
.group_mesh
.pending_commits
.get(&group_id)
.unwrap()
.len(),
1,
"Exactly one commit should be buffered"
);
}
#[test]
fn test_group_mls_pending_commit_buffer_cap() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let group_id = "group:cap-test".to_string();
for i in 0..(MAX_PENDING_COMMITS_PER_GROUP + 4) {
let data = serde_json::to_string(&GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(format!("commit-{}", i).as_bytes()),
epoch: i as u64,
affected_member: None,
role: None,
})
.unwrap();
protocol.buffer_pending_commit(&group_id, &format!("mid-{}", i), "alice", &data);
}
let buffered = protocol.group_mesh.pending_commits.get(&group_id).unwrap();
assert_eq!(
buffered.len(),
MAX_PENDING_COMMITS_PER_GROUP,
"Buffer should not exceed cap"
);
}
#[test]
fn test_group_mls_pending_commit_expired_entries_cleaned_up() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let group_id = "test-group".to_string();
let expired = PendingCommit {
sender: "alice".to_string(),
message_id: "test-mid-2".to_string(),
data: "{}".to_string(),
buffered_at: Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10),
retry_count: 0,
};
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(expired);
let recent = PendingCommit {
sender: "bob".to_string(),
message_id: "test-mid-3".to_string(),
data: "{}".to_string(),
buffered_at: Instant::now(),
retry_count: 0,
};
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(recent);
protocol.cleanup_group_message_dedup();
let buf = protocol.group_mesh.pending_commits.get(&group_id).unwrap();
assert_eq!(buf.len(), 1, "Only recent pending commit should survive");
assert_eq!(buf[0].sender, "bob".to_string());
}
#[test]
fn test_group_mls_commit_empty_ciphertext_not_buffered() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("No Buffer Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Remove,
ciphertext: String::new(),
epoch: 1,
affected_member: None,
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let _ = protocol.process_internal_message(&message);
assert!(
!protocol.group_mesh.pending_commits.contains_key(&group_id)
|| protocol
.group_mesh
.pending_commits
.get(&group_id)
.unwrap()
.is_empty(),
"Empty ciphertext commits must not be buffered"
);
}
#[test]
fn test_group_mls_double_leave_is_idempotent() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Double Leave").unwrap();
let group_id = info.group_id.as_str().to_string();
let result = protocol.leave_group(&group_id);
assert!(result.is_ok());
let result = protocol.leave_group(&group_id);
assert!(
result.is_ok(),
"Double leave should be idempotent (no error)"
);
assert!(!protocol.group_mesh.members.contains_key(&group_id));
let groups = protocol.list_groups().unwrap();
assert!(!groups.contains(&group_id));
}
#[test]
fn test_group_mls_invite_exceeds_max_members() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Cap Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let max_members = protocol.config.group.max_group_members;
let fake_members: Vec<String> = (0..max_members).map(|i| format!("member-{}", i)).collect();
protocol
.group_mesh
.members
.insert(group_id.clone(), fake_members);
let result = protocol.invite_to_group(&group_id, "new-invitee");
assert!(result.is_err(), "Should reject invite when at member cap");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("cannot exceed"),
"Error should mention cap, got: {}",
err_msg
);
}
#[test]
fn test_group_mls_invite_custom_max_members() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut config = create_test_config();
config.group.max_group_members = 3; let mut protocol = OfflineProtocol::new(config).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let info = protocol.create_group("Custom Cap Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("alice"), id("bob")],
);
let result = protocol.invite_to_group(&group_id, &id("carol"));
assert!(result.is_err(), "Should reject invite when at custom cap");
assert!(result.unwrap_err().to_string().contains("cannot exceed 3"));
}
#[test]
fn test_group_mls_invite_below_custom_cap_allowed() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut config = create_test_config();
config.group.max_group_members = 3;
let mut protocol = OfflineProtocol::new(config).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let info = protocol.create_group("Below Cap Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123"), id("alice")]);
let result = protocol.invite_to_group(&group_id, &id("bob"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("No key package"),
"Should fail for missing key package, not cap. Got: {}",
err_msg
);
}
#[test]
fn test_group_mls_invite_max_members_1_blocks_any_invite() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut config = create_test_config();
config.group.max_group_members = 1;
let mut protocol = OfflineProtocol::new(config).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let info = protocol.create_group("Solo Only").unwrap();
let group_id = info.group_id.as_str().to_string();
let result = protocol.invite_to_group(&group_id, &id("alice"));
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("cannot exceed 1"),
"max_group_members=1 should block all invites"
);
}
#[test]
fn test_group_mls_invite_large_max_members_not_rejected() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut config = create_test_config();
config.group.max_group_members = 10_000;
let mut protocol = OfflineProtocol::new(config).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let info = protocol.create_group("Large Cap Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let result = protocol.invite_to_group(&group_id, &id("alice"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
!err_msg.contains("cannot exceed"),
"Large cap should not trigger member limit. Got: {}",
err_msg
);
}
#[test]
fn test_group_mls_send_large_content_no_send_side_limit() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Large Content Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let large_content = "A".repeat(1_048_576);
let result = protocol.send_group_message(&group_id, &large_content, None, None);
assert!(result.is_ok(), "Large content should not be rejected");
assert!(result.unwrap().is_empty());
}
#[test]
fn test_group_mls_send_very_large_content_solo_group() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Very Large Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let content = "B".repeat(2 * 1_048_576);
let result = protocol.send_group_message(&group_id, &content, None, None);
assert!(
result.is_ok(),
"2 MB content should not be rejected at send"
);
}
#[test]
fn test_group_mls_drain_pending_commits_no_double_buffering() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Drain Test").unwrap();
let real_group_id = info.group_id.as_str().to_string();
let bad_commit = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: real_group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"not-a-real-mls-commit"),
epoch: 99,
affected_member: Some(id("carol")),
role: None,
};
let bad_data = serde_json::to_string(&bad_commit).unwrap();
protocol.group_mesh.pending_commits.insert(
real_group_id.clone(),
VecDeque::from(vec![
PendingCommit {
sender: id("alice"),
message_id: "test-mid-4".to_string(),
data: bad_data.clone(),
buffered_at: Instant::now(),
retry_count: 0,
},
PendingCommit {
sender: id("bob"),
message_id: "test-mid-5".to_string(),
data: bad_data,
buffered_at: Instant::now(),
retry_count: 0,
},
]),
);
protocol.drain_pending_commits(&real_group_id);
let remaining = protocol
.group_mesh
.pending_commits
.get(&real_group_id)
.map(|v| v.len())
.unwrap_or(0);
assert_eq!(
remaining, 0,
"Permanently rejected commits should not be re-buffered, got {}",
remaining
);
}
#[test]
fn test_group_mls_drain_pending_commits_expired_entries_dropped() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Drain Expiry Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let bad_commit = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"stale-commit"),
epoch: 1,
affected_member: Some(id("carol")),
role: None,
};
let data = serde_json::to_string(&bad_commit).unwrap();
protocol.group_mesh.pending_commits.insert(
group_id.clone(),
VecDeque::from(vec![PendingCommit {
sender: id("alice"),
message_id: "test-mid-6".to_string(),
data,
buffered_at: Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 1),
retry_count: 0,
}]),
);
protocol.drain_pending_commits(&group_id);
assert!(
!protocol.group_mesh.pending_commits.contains_key(&group_id),
"Expired pending commit should be dropped and entry cleaned up"
);
}
#[test]
fn test_group_mls_handle_commit_permanent_failure_not_buffered() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Buffer Test").unwrap();
let group_id = info.group_id.as_str().to_string();
assert!(protocol.group_mesh.pending_commits.is_empty());
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"fake-but-decodable-commit"),
epoch: 42,
affected_member: Some(id("carol")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let buffered = protocol
.group_mesh
.pending_commits
.get(&group_id)
.map(|b| b.len())
.unwrap_or(0);
assert_eq!(
buffered, 0,
"Permanent deserialization failure should not be buffered"
);
}
#[test]
fn test_group_mls_commit_rejected_not_buffered() {
let (mut protocol, _) = setup_started_with_events();
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: "group:reject-test".to_string(),
commit_type: GroupCommitType::Add,
ciphertext: String::new(),
epoch: 1,
affected_member: Some(id("carol")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
protocol.process_internal_message(&message);
assert!(
protocol.group_mesh.pending_commits.is_empty(),
"Rejected commits (empty ciphertext) should not be buffered"
);
let bad_content = format!("{}{{invalid json", internal_prefixes::GROUP_MLS_COMMIT);
let bad_message = make_message(&id("alice"), &id("user123"), &bad_content);
protocol.process_internal_message(&bad_message);
assert!(
protocol.group_mesh.pending_commits.is_empty(),
"Rejected commits (bad JSON) should not be buffered"
);
}
#[test]
fn test_group_mls_buffer_pending_commit_respects_cap() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let group_id = "group:cap-test".to_string();
for i in 0..MAX_PENDING_COMMITS_PER_GROUP {
protocol.buffer_pending_commit(
&group_id,
&format!("mid-{}", i),
&format!("sender-{}", i),
&format!("data-{}", i),
);
}
assert_eq!(
protocol.group_mesh.pending_commits[&group_id].len(),
MAX_PENDING_COMMITS_PER_GROUP
);
protocol.buffer_pending_commit(&group_id, "mid-new", "sender-new", "data-new");
let buf = &protocol.group_mesh.pending_commits[&group_id];
assert_eq!(buf.len(), MAX_PENDING_COMMITS_PER_GROUP);
assert_eq!(buf[0].sender, "sender-1");
assert_eq!(buf[buf.len() - 1].sender, "sender-new");
}
#[test]
fn test_group_mls_invite_respects_max_group_members() {
let mut config = create_test_config();
config.group.max_group_members = 2;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(config).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let info = protocol.create_group("Capped Group").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123"), id("alice")]);
let result = protocol.invite_to_group(&group_id, &id("bob"));
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("cannot exceed"),
"Error should mention the group member limit"
);
}
#[test]
fn test_group_mls_invite_to_group_end_to_end() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let alice_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let alice_events_clone = alice_events.clone();
alice.on_event(move |event| {
alice_events_clone.lock().unwrap().push(event);
});
let group_info = alice.create_group("Invite E2E Test").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000, },
);
let invite_result = alice.invite_to_group(&group_id, &id("bob"));
assert!(
invite_result.is_ok(),
"invite_to_group should succeed: {:?}",
invite_result.err()
);
let events = alice_events.lock().unwrap();
let added_event = events.iter().find(|e| {
matches!(e, Event::GroupMemberAdded { group_id: gid, user_id, added_by, .. }
if gid == &group_id && user_id == &id("bob") && added_by == &id("alice"))
});
assert!(
added_event.is_some(),
"Expected GroupMemberAdded event for bob"
);
let cached = alice.group_mesh.members.get(&group_id).unwrap();
assert!(
cached.contains(&id("bob")),
"Bob should be in Alice's member cache after invite"
);
}
#[test]
fn test_group_mls_invite_and_bob_joins_via_welcome() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group("Join E2E Test").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice.invite_to_group(&group_id, &id("bob")).unwrap();
let welcome_sent = alice.outbox_messages().any(|msg| {
msg.recipient.as_str() == &id("bob")
&& msg
.content
.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
});
assert!(
welcome_sent,
"Alice's outbox should contain a Welcome message for Bob"
);
}
#[test]
fn test_group_mls_invite_sends_commit_to_existing_members() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
let carol_proto = OfflineProtocol::new(create_test_config_for_user("carol")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
drop(carol_proto);
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group("Commit Fan-out Test").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap();
}
alice.refresh_group_members(&group_id).unwrap();
let carol_mls_manager = crate::test_identity::manager_for("carol", storage_c);
let carol_kp = carol_mls_manager.generate_key_package().unwrap();
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("carol"),
ReceivedKeyPackage {
key_package_data: carol_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice.clear_outbox();
alice.invite_to_group(&group_id, &id("carol")).unwrap();
let commit_to_bob = alice.outbox_messages().any(|msg| {
msg.recipient.as_str() == &id("bob")
&& msg.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
});
assert!(
commit_to_bob,
"Alice should have sent a Commit to Bob (existing member) during Carol's invite"
);
let welcome_to_carol = alice.outbox_messages().any(|msg| {
msg.recipient.as_str() == &id("carol")
&& msg
.content
.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
});
assert!(
welcome_to_carol,
"Alice should have sent a Welcome to Carol"
);
let commit_to_carol = alice.outbox_messages().any(|msg| {
msg.recipient.as_str() == &id("carol")
&& msg.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
});
assert!(
!commit_to_carol,
"Carol should NOT receive a Commit (she gets the Welcome instead)"
);
}
#[test]
fn test_group_mls_invite_expired_key_package_rejected() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Expiry Test").unwrap();
let group_id = info.group_id.as_str().to_string();
use crate::protocol::ReceivedKeyPackage;
protocol.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: vec![1, 2, 3],
local_expires_at_ms: 0, },
);
let result = protocol.invite_to_group(&group_id, &id("bob"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("expired"),
"Error should mention expiry. Got: {}",
err_msg
);
assert!(
!protocol.pending_key_packages.contains_key(&id("bob")),
"Expired key package should be cleaned up"
);
}
#[test]
fn test_group_mls_max_group_members_enforced_with_valid_key_package() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut config = create_test_config_for_user("alice");
config.group.max_group_members = 1; let mut alice = OfflineProtocol::new(config).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
alice.start().unwrap();
let info = alice.create_group("Cap Enforcement").unwrap();
let group_id = info.group_id.as_str().to_string();
let bob_mls = crate::test_identity::manager_for("bob", storage_b);
let bob_kp = bob_mls.generate_key_package().unwrap();
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
let result = alice.invite_to_group(&group_id, &id("bob"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("cannot exceed"),
"Should fail at member cap, not at MLS layer. Got: {}",
err_msg
);
}
#[test]
fn test_group_mls_remove_from_group_with_real_members() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Remove Real Test");
let members_before = alice.group_mesh.members.get(&group_id).unwrap();
assert!(
members_before.contains(&id("bob")),
"Bob should be in group before removal"
);
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
alice.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
alice.clear_outbox();
let result = alice.remove_from_group(&group_id, &id("bob"));
assert!(
result.is_ok(),
"remove_from_group should succeed: {:?}",
result.err()
);
let members_after = alice.group_mesh.members.get(&group_id).unwrap();
assert!(
!members_after.contains(&id("bob")),
"Bob should be removed from member cache"
);
let events = events.lock().unwrap();
let removed_event = events.iter().find(|e| {
matches!(e, Event::GroupMemberRemoved { group_id: gid, user_id, removed_by, .. }
if gid == &group_id && user_id == &id("bob") && removed_by == &id("alice"))
});
assert!(
removed_event.is_some(),
"Expected GroupMemberRemoved event for bob"
);
}
#[test]
fn test_group_mls_invite_multiple_members_successively() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
alice.start().unwrap();
let group_info = alice.create_group("Multi Invite Test").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_mls = crate::test_identity::manager_for("bob", storage_b);
let bob_kp = bob_mls.generate_key_package().unwrap();
let carol_mls = crate::test_identity::manager_for("carol", storage_c);
let carol_kp = carol_mls.generate_key_package().unwrap();
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice.invite_to_group(&group_id, &id("bob")).unwrap();
let members = alice.group_mesh.members.get(&group_id).unwrap();
assert!(members.contains(&id("bob")));
alice.pending_key_packages.insert(
id("carol"),
ReceivedKeyPackage {
key_package_data: carol_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice.invite_to_group(&group_id, &id("carol")).unwrap();
let members = alice.group_mesh.members.get(&group_id).unwrap();
assert!(members.contains(&id("bob")));
assert!(members.contains(&id("carol")));
assert!(members.contains(&id("alice")));
assert_eq!(members.len(), 3);
}
#[test]
fn test_group_mls_commit_group_not_found_is_buffered_for_welcome_race() {
let (mut protocol, _) = setup_started_with_events();
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: "group:does-not-exist".to_string(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"some-commit-data"),
epoch: 1,
affected_member: Some(id("carol")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let _ = protocol.process_internal_message(&message);
assert_eq!(
protocol
.group_mesh
.pending_commits
.get("group:does-not-exist")
.map(|b| b.len()),
Some(1),
"Commit for an unknown group may have outrun its Welcome and must be buffered"
);
}
#[test]
fn test_group_mls_commit_bad_deserialization_is_rejected_not_retriable() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Deser Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"this-is-not-mls"),
epoch: 1,
affected_member: Some(id("carol")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("alice"), &id("user123"), &content);
let _ = protocol.process_internal_message(&message);
let buffered = protocol
.group_mesh
.pending_commits
.get(&group_id)
.map(|b| b.len())
.unwrap_or(0);
assert!(
buffered <= 1,
"Bad ciphertext should be rejected or buffered at most once, got {}",
buffered
);
}
#[test]
fn test_group_mls_multiple_groups_independent() {
let (mut protocol, events) = setup_started_with_events();
let info_a = protocol.create_group("Group Alpha").unwrap();
let info_b = protocol.create_group("Group Beta").unwrap();
let group_a = info_a.group_id.as_str().to_string();
let group_b = info_b.group_id.as_str().to_string();
assert_ne!(group_a, group_b);
let groups = protocol.list_groups().unwrap();
assert!(groups.contains(&group_a));
assert!(groups.contains(&group_b));
assert_eq!(groups.len(), 2);
protocol.leave_group(&group_a).unwrap();
let groups = protocol.list_groups().unwrap();
assert!(!groups.contains(&group_a));
assert!(groups.contains(&group_b));
let result = protocol.send_group_message(&group_b, "hello beta", None, None);
assert!(result.is_ok());
let events = events.lock().unwrap();
let created_count = events
.iter()
.filter(|e| matches!(e, Event::GroupCreated { .. }))
.count();
assert_eq!(created_count, 2, "Expected 2 GroupCreated events");
}
#[test]
fn test_group_mls_max_group_members_boundary() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let mut config = create_test_config_for_user("alice");
config.group.max_group_members = 2;
let mut alice = OfflineProtocol::new(config).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
alice.start().unwrap();
let info = alice.create_group("Tiny Group").unwrap();
let group_id = info.group_id.as_str().to_string();
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let bob_mls = crate::test_identity::manager_for("bob", storage_b);
let bob_kp = bob_mls.generate_key_package().unwrap();
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice.invite_to_group(&group_id, &id("bob")).unwrap();
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let carol_mls = crate::test_identity::manager_for("carol", storage_c);
let carol_kp = carol_mls.generate_key_package().unwrap();
alice.pending_key_packages.insert(
id("carol"),
ReceivedKeyPackage {
key_package_data: carol_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
let result = alice.invite_to_group(&group_id, &id("carol"));
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("cannot exceed"),
"Error should mention exceeding member limit"
);
}
#[test]
fn test_group_mls_send_message_with_reply_to() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Reply Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123"), id("bob")]);
let result =
protocol.send_group_message(&group_id, "replying here", None, Some("msg-original-123"));
assert!(result.is_ok());
let msg_ids = result.unwrap();
assert_eq!(msg_ids.len(), 1, "Should send to bob only");
}
#[test]
fn test_group_mls_leave_self_only_group() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Solo Leave").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.leave_group(&group_id).unwrap();
assert!(!protocol.group_mesh.members.contains_key(&group_id));
let groups = protocol.list_groups().unwrap();
assert!(!groups.contains(&group_id));
}
#[test]
fn test_group_mls_dedup_independent_per_message_id() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Multi Msg Test");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let enc1 = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls.encrypt_for_group(&gid, b"First message").unwrap()
};
let enc2 = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.encrypt_for_group(&gid, b"Second message")
.unwrap()
};
for encrypted in [&enc1, &enc2] {
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: group_id.clone(),
ciphertext: base64_encode(&encrypted.ciphertext),
epoch: encrypted.epoch,
reply_to: None,
forward_info: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload).unwrap()
);
let bob_message = make_message(&id("alice"), &id("bob"), &content);
bob.process_internal_message(&bob_message);
}
let events = bob_events.lock().unwrap();
let received_count = events
.iter()
.filter(|e| matches!(e, Event::GroupMessageReceived { .. }))
.count();
assert_eq!(
received_count, 2,
"Both distinct messages should be received (not deduplicated)"
);
}
#[test]
fn test_group_mls_expired_key_package_rejected() {
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Expired KP Test").unwrap();
let group_id = info.group_id.as_str().to_string();
use crate::protocol::ReceivedKeyPackage;
protocol.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: vec![1, 2, 3],
local_expires_at_ms: 0, },
);
let result = protocol.invite_to_group(&group_id, &id("bob"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("expired"));
assert!(
!protocol.pending_key_packages.contains_key(&id("bob")),
"Expired key package should be cleaned up"
);
}
#[test]
fn test_group_mls_pending_commit_drain_cascades() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let group_id = "group:cascade-test".to_string();
let mut buf = VecDeque::new();
buf.push_back(PendingCommit {
sender: "alice".to_string(),
message_id: "test-mid-7".to_string(),
data: serde_json::to_string(&GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"commit-1"),
epoch: 1,
affected_member: None,
role: None,
})
.unwrap(),
buffered_at: Instant::now(),
retry_count: 0,
});
buf.push_back(PendingCommit {
sender: "bob".to_string(),
message_id: "test-mid-8".to_string(),
data: serde_json::to_string(&GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(b"commit-2"),
epoch: 2,
affected_member: None,
role: None,
})
.unwrap(),
buffered_at: Instant::now(),
retry_count: 0,
});
protocol
.group_mesh
.pending_commits
.insert(group_id.clone(), buf);
protocol.drain_pending_commits(&group_id);
let remaining = protocol
.group_mesh
.pending_commits
.get(&group_id)
.map(|b| b.len())
.unwrap_or(0);
assert_eq!(
remaining, 0,
"All pending commits should be rejected when MLS is unavailable"
);
}
#[test]
fn test_relay_sync_on_internet_available_transition() {
use offline_protocol_transport::mock::MockTransport;
let (mut protocol, _) = setup_started_with_events();
let info = protocol.create_group("Relay Sync Test").unwrap();
let group_id = info.group_id.as_str().to_string();
assert!(
!protocol.group_mesh.relay_synced.contains(&group_id),
"Group should not be relay-synced without Internet"
);
assert!(!protocol.group_mesh.internet_was_available);
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.check_relay_group_sync();
assert!(protocol.group_mesh.internet_was_available);
assert!(
internet_handle.sent_messages().iter().any(|m| m
.content
.starts_with(internal_prefixes::GROUP_RELAY_REGISTER)),
"Registration frame should be sent when Internet becomes available"
);
assert!(
!protocol.group_mesh.relay_synced.contains(&group_id),
"Group must not be relay-synced before the relay acknowledges"
);
}
#[test]
fn test_relay_sync_cleared_on_internet_lost() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Relay Lost Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.internet_was_available = true;
protocol.group_mesh.relay_synced.insert(group_id.clone());
protocol
.transport_manager_mut()
.remove_transport(TransportType::Internet);
protocol.check_relay_group_sync();
assert!(!protocol.group_mesh.internet_was_available);
assert!(
protocol.group_mesh.relay_synced.is_empty(),
"Relay sync state should be cleared when Internet is lost"
);
}
fn sync_changed_events(events: &Arc<Mutex<Vec<Event>>>) -> Vec<(String, bool, String)> {
events
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
Event::GroupRelaySyncChanged {
group_id,
synced,
reason,
} => Some((group_id.clone(), *synced, reason.clone())),
_ => None,
})
.collect()
}
#[test]
fn test_group_relay_sync_changed_event_lifecycle() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let info = protocol.create_group("Sync Event Test").unwrap();
let group_id = info.group_id.as_str().to_string();
assert_eq!(
protocol.group_relay_sync_state(&group_id),
crate::group_mesh::RelaySyncState::Pending
);
assert!(sync_changed_events(&events).is_empty());
let ack = make_message(
"relay",
&id("user123"),
&format!(
"__GROUP_CREATED__{{\"group_id\":\"{}\",\"name\":\"Sync Event Test\"}}",
group_id
),
);
protocol.process_internal_message_via(&ack, Some(TransportType::Internet));
assert_eq!(
sync_changed_events(&events),
vec![(group_id.clone(), true, "registered".to_string())]
);
assert_eq!(
protocol.group_relay_sync_state(&group_id),
crate::group_mesh::RelaySyncState::Synced
);
protocol.process_internal_message_via(&ack, Some(TransportType::Internet));
assert_eq!(sync_changed_events(&events).len(), 1);
protocol.group_mesh.relay_register_pending.insert(
group_id.clone(),
crate::group_mesh::RelayRegisterPending {
armed_at: chrono::Utc::now(),
attempts: 1,
},
);
protocol.process_internal_message_via(&ack, Some(TransportType::Internet));
assert_eq!(
sync_changed_events(&events),
vec![
(group_id.clone(), true, "registered".to_string()),
(group_id.clone(), true, "registered".to_string()),
]
);
let error = make_message(
"relay",
&id("user123"),
&format!(
"__GROUP_ERROR__{{\"reason\":\"Only admins can sync this group\",\"group_id\":\"{}\"}}",
group_id
),
);
protocol.process_internal_message_via(&error, Some(TransportType::Internet));
assert_eq!(
sync_changed_events(&events).last().unwrap(),
&(group_id.clone(), false, "error".to_string())
);
assert_eq!(
protocol.group_relay_sync_state(&group_id),
crate::group_mesh::RelaySyncState::Unsynced
);
protocol.process_internal_message(&error);
assert_eq!(sync_changed_events(&events).len(), 3);
protocol.stop().unwrap();
}
#[test]
fn test_group_error_reason_is_classified_not_quoted_from_the_wire() {
let (mut protocol, events) = setup_started_with_events();
let victim = id("victim");
let planted =
format!("LEAKMARKER-9f3: {victim} was denied in group secret-group-42 (see audit)");
let frame =
format!("__GROUP_ERROR__{{\"reason\":\"{planted}\",\"group_id\":\"secret-group-42\"}}");
assert!(
frame.contains("LEAKMARKER-9f3") && frame.contains(victim.as_str()),
"fixture must actually carry the marker and the address"
);
let msg = make_unsigned_message("relay", &id("user123"), &frame);
protocol.process_internal_message_via(&msg, Some(TransportType::Internet));
let collected = events.lock().unwrap();
let errors: Vec<(&String, &Option<String>)> = collected
.iter()
.filter_map(|e| match e {
Event::GroupError { reason, group_id } => Some((reason, group_id)),
_ => None,
})
.collect();
assert_eq!(errors.len(), 1, "expected exactly one GroupError event");
let (reason, group_id) = errors[0];
assert_eq!(
reason, "error",
"unrecognized relay wording must classify to the closed fallback"
);
assert!(
!reason.contains("LEAKMARKER-9f3"),
"relay-chosen text reached the event: {reason}"
);
assert!(
!reason.contains(victim.as_str()),
"an address the sender wrote reached the event: {reason}"
);
assert_eq!(group_id.as_deref(), Some("secret-group-42"));
}
#[test]
fn test_group_error_known_relay_wordings_map_to_distinct_codes() {
let cases = [
("Group not found", "not_found"),
("Only admins can sync this group", "sync_denied"),
("Not a member of group secret-group-42", "error"),
];
for (wording, expected) in cases {
let (mut protocol, events) = setup_started_with_events();
let frame = format!("__GROUP_ERROR__{{\"reason\":\"{wording}\",\"group_id\":\"g-1\"}}");
let msg = make_unsigned_message("relay", &id("user123"), &frame);
protocol.process_internal_message_via(&msg, Some(TransportType::Internet));
let collected = events.lock().unwrap();
let reason = collected
.iter()
.find_map(|e| match e {
Event::GroupError { reason, .. } => Some(reason.clone()),
_ => None,
})
.expect("GroupError event");
assert_eq!(reason, expected, "wording {wording:?} misclassified");
}
}
#[test]
fn test_relay_register_ack_timeout_emits_sync_changed() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let info = protocol.create_group("Timeout Event Test").unwrap();
let group_id = info.group_id.as_str().to_string();
{
let pending = protocol
.group_mesh
.relay_register_pending
.get_mut(&group_id)
.unwrap();
pending.armed_at = chrono::Utc::now() - chrono::Duration::seconds(31);
pending.attempts = 3;
}
protocol.process_relay_register_retries();
assert_eq!(
sync_changed_events(&events),
vec![(group_id.clone(), false, "ack_timeout".to_string())]
);
assert_eq!(
protocol.group_relay_sync_state(&group_id),
crate::group_mesh::RelaySyncState::Unsynced
);
protocol.group_mesh.relay_synced.insert(group_id.clone());
protocol.group_mesh.relay_register_pending.insert(
group_id.clone(),
crate::group_mesh::RelayRegisterPending {
armed_at: chrono::Utc::now() - chrono::Duration::seconds(31),
attempts: 3,
},
);
protocol.process_relay_register_retries();
assert_eq!(sync_changed_events(&events).len(), 1);
protocol.stop().unwrap();
}
#[test]
fn test_internet_lost_emits_sync_changed_per_group() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
protocol.group_mesh.internet_was_available = true;
protocol
.group_mesh
.relay_synced
.insert("synced-group".to_string());
protocol.group_mesh.relay_register_pending.insert(
"pending-group".to_string(),
crate::group_mesh::RelayRegisterPending {
armed_at: chrono::Utc::now(),
attempts: 1,
},
);
protocol
.transport_manager_mut()
.remove_transport(TransportType::Internet);
protocol.check_relay_group_sync();
let mut emitted = sync_changed_events(&events);
emitted.sort();
assert_eq!(
emitted,
vec![
(
"pending-group".to_string(),
false,
"internet_dropped".to_string()
),
(
"synced-group".to_string(),
false,
"internet_dropped".to_string()
),
]
);
protocol.stop().unwrap();
}
#[test]
fn test_request_group_relay_registration() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
assert!(protocol
.request_group_relay_registration("no-such-group")
.is_err());
let info = protocol.create_group("Request Reg Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.start().unwrap();
assert_eq!(
protocol.request_group_relay_registration(&group_id).ok(),
Some(false)
);
assert_eq!(
protocol.group_relay_sync_state(&group_id),
crate::group_mesh::RelaySyncState::Unsynced
);
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
assert_eq!(
protocol.request_group_relay_registration(&group_id).ok(),
Some(true)
);
assert_eq!(
protocol.group_relay_sync_state(&group_id),
crate::group_mesh::RelaySyncState::Pending
);
let frames_after_request = internet_handle
.sent_messages()
.iter()
.filter(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_REGISTER)
})
.count();
assert_eq!(frames_after_request, 1);
protocol.group_mesh.relay_register_pending.remove(&group_id);
protocol.group_mesh.relay_synced.insert(group_id.clone());
assert_eq!(
protocol.request_group_relay_registration(&group_id).ok(),
Some(true)
);
let frames_after_synced = internet_handle
.sent_messages()
.iter()
.filter(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_REGISTER)
})
.count();
assert_eq!(
frames_after_synced, 1,
"an already-synced group must not re-register"
);
protocol.stop().unwrap();
}
fn relay_registration_names(
handle: &offline_protocol_transport::mock::MockTransport,
group_id: &str,
) -> Vec<Option<String>> {
handle
.sent_messages()
.iter()
.filter_map(|m| {
m.content
.strip_prefix(internal_prefixes::GROUP_RELAY_REGISTER)
.map(str::to_string)
})
.map(|json| {
let payload: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
payload.get("group_id").and_then(|v| v.as_str()),
Some(group_id)
);
payload
.get("group_name")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.collect()
}
#[test]
fn test_deferred_relay_registration_carries_stored_group_name() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let info = protocol.create_group("Offline Created").unwrap();
let group_id = info.group_id.as_str().to_string();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
assert!(!protocol.group_mesh.internet_was_available);
protocol.check_relay_group_sync();
assert_eq!(
relay_registration_names(&internet_handle, &group_id),
vec![Some("Offline Created".to_string())],
"re-sync registration must carry the stored group name"
);
protocol.group_mesh.relay_register_pending.remove(&group_id);
assert_eq!(
protocol.request_group_relay_registration(&group_id).ok(),
Some(true)
);
assert_eq!(
relay_registration_names(&internet_handle, &group_id),
vec![
Some("Offline Created".to_string()),
Some("Offline Created".to_string())
],
"requested registration must carry the stored group name"
);
protocol.stop().unwrap();
}
#[test]
fn test_joiner_relay_registration_carries_welcome_group_name() {
use offline_protocol_transport::mock::MockTransport;
let (_alice, mut bob, _events, group_id, welcome_json) = setup_race_alice_bob();
bob.start().unwrap();
bob.handle_group_mls_welcome("welcome-carries-name", &id("alice"), &welcome_json);
let persisted = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.get_group_metadata(&gid)
.unwrap()
.and_then(|m| m.name)
};
assert_eq!(
persisted.as_deref(),
Some("Race Group"),
"a joiner must persist the group name the Welcome carried"
);
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
bob.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
assert!(!bob.group_mesh.internet_was_available);
bob.check_relay_group_sync();
assert_eq!(
relay_registration_names(&internet_handle, &group_id),
vec![Some("Race Group".to_string())],
"a joiner's re-sync registration must carry the group name"
);
bob.stop().unwrap();
}
#[test]
fn test_relay_sync_disabled_config() {
use offline_protocol_transport::mock::MockTransport;
let mut config = create_test_config();
config.group.relay_enabled = false;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(config).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("No Relay Test").unwrap();
let group_id = info.group_id.as_str().to_string();
assert!(
!protocol.group_mesh.relay_synced.contains(&group_id),
"Group should not be relay-synced when relay is disabled"
);
protocol.check_relay_group_sync();
assert!(!protocol.group_mesh.internet_was_available);
}
fn broadcast_enabled_config() -> crate::ProtocolConfig {
let mut config = create_test_config();
config.group.relay_broadcast_enabled = true;
config
}
fn grant_group_delivery_v3(protocol: &mut OfflineProtocol) {
protocol.set_relay_capabilities(vec![
crate::group_mesh::RELAY_CAP_GROUP_DELIVERY_V3.to_string()
]);
}
#[test]
fn test_relay_broadcast_used_when_synced() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Relay Broadcast Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol"), id("dave")],
);
protocol.group_mesh.relay_synced.insert(group_id.clone());
grant_group_delivery_v3(&mut protocol);
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let msg_ids = protocol
.send_group_message(&group_id, "hello relay", None, None)
.unwrap();
assert_eq!(
msg_ids.len(),
1,
"Relay broadcast should return exactly 1 message ID"
);
assert!(
protocol
.group_mesh
.relay_broadcast_pending
.contains_key(&msg_ids[0].as_str()),
"Broadcast must be tracked awaiting its delivery report"
);
let events = events.lock().unwrap();
let sent_event = events.iter().find(|e| {
matches!(e, Event::GroupMessageSent { group_id: gid, member_count, .. }
if gid == &group_id && *member_count == 3)
});
assert!(
sent_event.is_some(),
"Expected GroupMessageSent with member_count=3 (bob, carol, dave)"
);
}
#[test]
fn test_relay_broadcast_without_capability_uses_per_member_fanout() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let config = create_test_config();
assert!(
config.group.relay_broadcast_enabled,
"Relay broadcast defaults on (the capability gate is what keeps it safe)"
);
let mut protocol = OfflineProtocol::new(config).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Default Off Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol"), id("dave")],
);
protocol.group_mesh.relay_synced.insert(group_id.clone());
let msg_ids = protocol
.send_group_message(&group_id, "hello fanout", None, None)
.unwrap();
assert_eq!(
msg_ids.len(),
3,
"Expected one message ID per member (bob, carol, dave), not a single broadcast ID"
);
let broadcasts = internet_handle
.sent_messages()
.into_iter()
.filter(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_BROADCAST)
})
.count();
assert_eq!(broadcasts, 0, "No broadcast frame should be emitted");
}
#[test]
fn test_v2_only_capability_keeps_per_member_fanout() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("V2 Only Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol"), id("dave")],
);
protocol.group_mesh.relay_synced.insert(group_id.clone());
protocol.set_relay_capabilities(vec!["group_delivery_v2".to_string()]);
let msg_ids = protocol
.send_group_message(&group_id, "hello fanout", None, None)
.unwrap();
assert_eq!(
msg_ids.len(),
3,
"A v2-only relay must get one message per member, not a broadcast"
);
let broadcasts = internet_handle
.sent_messages()
.into_iter()
.filter(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_BROADCAST)
})
.count();
assert_eq!(broadcasts, 0, "No broadcast frame should be emitted");
}
#[test]
fn test_relay_broadcast_frame_is_unacked_and_not_retried() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Unacked Broadcast Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol")],
);
protocol.group_mesh.relay_synced.insert(group_id.clone());
grant_group_delivery_v3(&mut protocol);
let msg_ids = protocol
.send_group_message(&group_id, "hello relay", None, None)
.unwrap();
assert_eq!(msg_ids.len(), 1, "Expected the single broadcast ID");
let broadcast_id = msg_ids[0].clone();
assert!(
!protocol.is_tracked_for_delivery(&broadcast_id),
"Broadcast frame must leave no pending ACK, outbox entry, or retry entry — \
it can never be ACKed, so any of those means it gets retransmitted to the \
retry cap and then reported failed"
);
let broadcast_frame = internet_handle
.sent_messages()
.into_iter()
.find(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_BROADCAST)
})
.expect("Broadcast frame should have been sent over Internet");
assert!(
!broadcast_frame.requires_ack,
"Broadcast frame must be sent with requires_ack = false"
);
assert!(
!protocol.is_tracked_for_delivery(&broadcast_frame.id),
"The hint frame's own id must stay off the ACK ladder too"
);
let payload_json = broadcast_frame
.content
.strip_prefix(internal_prefixes::GROUP_RELAY_BROADCAST)
.unwrap();
let payload: serde_json::Value = serde_json::from_str(payload_json).unwrap();
assert_eq!(
payload["message_id"].as_str(),
Some(broadcast_id.as_str().as_str()),
"Broadcast payload must carry the logical message id"
);
}
#[test]
fn test_relay_registration_frame_is_unacked_and_not_retried() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Registration Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let register_frame = internet_handle
.sent_messages()
.into_iter()
.find(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_REGISTER)
})
.expect("Registration frame should have been sent over Internet");
assert!(
!register_frame.requires_ack,
"Registration frame must be sent with requires_ack = false"
);
assert!(
!protocol.is_tracked_for_delivery(®ister_frame.id),
"Registration frame must leave no pending ACK, outbox entry, or retry entry"
);
assert!(
protocol
.group_mesh
.relay_register_pending
.contains_key(&group_id),
"Registration retry must be armed on the relay_register_pending tracker"
);
}
#[test]
fn test_relay_hint_frames_pin_to_internet_not_mesh() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let wifi = MockTransport::new(TransportType::WiFiDirect);
wifi.start().unwrap();
let wifi_handle = wifi.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::WiFiDirect, Box::new(wifi));
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Pinning Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol")],
);
protocol.group_mesh.relay_synced.insert(group_id.clone());
grant_group_delivery_v3(&mut protocol);
protocol
.send_group_message(&group_id, "hello relay", None, None)
.unwrap();
let is_hint = |content: &str| {
content.starts_with(internal_prefixes::GROUP_RELAY_BROADCAST)
|| content.starts_with(internal_prefixes::GROUP_RELAY_REGISTER)
};
let mesh_hints = wifi_handle
.sent_messages()
.into_iter()
.filter(|m| is_hint(&m.content))
.count();
assert_eq!(
mesh_hints, 0,
"Relay hint frames must never be handed to a mesh transport"
);
let internet_hints = internet_handle
.sent_messages()
.into_iter()
.filter(|m| is_hint(&m.content))
.count();
assert!(
internet_hints >= 2,
"Both the registration and broadcast hints should go out over Internet, got {}",
internet_hints
);
}
#[test]
fn test_relay_broadcast_falls_back_when_internet_unavailable() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let wifi = MockTransport::new(TransportType::WiFiDirect);
wifi.start().unwrap();
let wifi_handle = wifi.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::WiFiDirect, Box::new(wifi));
protocol.start().unwrap();
let info = protocol.create_group("Stale Sync Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol")],
);
protocol.group_mesh.relay_synced.insert(group_id.clone());
grant_group_delivery_v3(&mut protocol);
let msg_ids = protocol
.send_group_message(&group_id, "hello fallback", None, None)
.unwrap();
assert_eq!(
msg_ids.len(),
2,
"Expected per-member fan-out (bob, carol) when Internet is unavailable"
);
let broadcasts = wifi_handle
.sent_messages()
.into_iter()
.filter(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_BROADCAST)
})
.count();
assert_eq!(
broadcasts, 0,
"Broadcast frame must never be swallowed by the mesh transport"
);
}
fn arm_fake_broadcast(
protocol: &mut OfflineProtocol,
group_id: &str,
logical_id: &str,
members: &[&str],
) {
protocol.group_mesh.members.insert(
group_id.to_string(),
members.iter().map(|m| m.to_string()).collect(),
);
protocol
.group_mesh
.relay_synced
.insert(group_id.to_string());
protocol.group_mesh.relay_broadcast_pending.insert(
logical_id.to_string(),
RelayBroadcastPending {
group_id: group_id.to_string(),
ciphertext_b64: base64_encode(b"broadcast-ct"),
epoch: 3,
reply_to: None,
forward_info: None,
priority: offline_protocol_core::MessagePriority::Medium,
armed_at: chrono::Utc::now(),
attempts: 1,
},
);
}
fn grp_mls_frames(
handle: &offline_protocol_transport::mock::MockTransport,
) -> Vec<(String, serde_json::Value)> {
handle
.sent_messages()
.into_iter()
.filter_map(|m| {
m.content
.strip_prefix(internal_prefixes::GROUP_MLS_MSG)
.map(|p| {
(
m.recipient.as_str().to_string(),
serde_json::from_str::<serde_json::Value>(p).unwrap(),
)
})
})
.collect()
}
#[test]
fn test_relay_broadcast_tracker_is_bounded_by_downgrading_oldest() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Bounded Tracker").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123"), id("bob")]);
protocol.group_mesh.relay_synced.insert(group_id.clone());
grant_group_delivery_v3(&mut protocol);
for i in 0..MAX_RELAY_BROADCAST_PENDING {
arm_fake_broadcast(
&mut protocol,
"other-group",
&format!("logical-{i}"),
&[&id("carol")],
);
}
let oldest = "logical-0";
protocol
.group_mesh
.relay_broadcast_pending
.get_mut(oldest)
.unwrap()
.armed_at = chrono::Utc::now() - chrono::Duration::seconds(600);
protocol
.send_group_message(&group_id, "over the cap", None, None)
.unwrap();
assert_eq!(
protocol.group_mesh.relay_broadcast_pending.len(),
MAX_RELAY_BROADCAST_PENDING,
"the tracker stays at its cap"
);
assert!(
!protocol
.group_mesh
.relay_broadcast_pending
.contains_key(oldest),
"the oldest entry is the one evicted"
);
let reissued: Vec<_> = grp_mls_frames(&internet_handle)
.into_iter()
.filter(|(_, p)| p.get("message_id").and_then(|v| v.as_str()) == Some(oldest))
.collect();
assert_eq!(
reissued.len(),
1,
"the evicted broadcast is downgraded to per-member fan-out, not dropped"
);
assert_eq!(reissued[0].0, id("carol"));
}
#[test]
fn test_broadcast_report_all_reached_settles_without_reissue() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
grant_group_delivery_v3(&mut protocol);
let logical = "0b0b0b0b-1111-2222-3333-444444444444";
arm_fake_broadcast(
&mut protocol,
"grp-report",
logical,
&[&id("user123"), &id("bob"), &id("carol"), &id("dave")],
);
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let report = serde_json::json!({
"type": "GroupMessageSent",
"group_id": "grp-report",
"message_id": logical,
"timestamp": "2026-07-31T00:00:00Z",
"delivered": [&id("bob")],
"pushed": [&id("carol"), &id("dave")],
"missed": [],
})
.to_string();
protocol
.handle_relay_group_delivery_report(&report)
.unwrap();
assert!(
!protocol
.group_mesh
.relay_broadcast_pending
.contains_key(logical),
"Report must settle the tracker"
);
assert!(
grp_mls_frames(&internet_handle).is_empty(),
"No per-member copies should be re-sent when everyone was reached"
);
let events = events.lock().unwrap();
let report_event = events
.iter()
.find_map(|e| match e {
Event::GroupMessageDeliveryReport {
group_id,
message_id,
delivered,
pushed,
missed_reissued,
} => Some((
group_id.clone(),
message_id.clone(),
delivered.clone(),
pushed.clone(),
missed_reissued.clone(),
)),
_ => None,
})
.expect("Expected GroupMessageDeliveryReport event");
assert_eq!(report_event.0, "grp-report");
assert_eq!(report_event.1, logical);
assert_eq!(report_event.2, vec![id("bob")]);
assert_eq!(report_event.3, vec![id("carol"), id("dave")]);
assert!(report_event.4.is_empty());
}
#[test]
fn test_broadcast_report_reissues_missed_and_unnamed_members() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
grant_group_delivery_v3(&mut protocol);
let logical = "0c0c0c0c-1111-2222-3333-444444444444";
arm_fake_broadcast(
&mut protocol,
"grp-missed",
logical,
&[&id("user123"), &id("bob"), &id("carol"), &id("dave")],
);
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let report = serde_json::json!({
"type": "GroupMessageSent",
"group_id": "grp-missed",
"message_id": logical,
"timestamp": "2026-07-31T00:00:00Z",
"delivered": [&id("bob")],
"pushed": [],
"missed": [{"username": &id("carol"), "reason": "offline_no_push"}],
})
.to_string();
protocol
.handle_relay_group_delivery_report(&report)
.unwrap();
let frames = grp_mls_frames(&internet_handle);
let mut recipients: Vec<&str> = frames.iter().map(|(r, _)| r.as_str()).collect();
recipients.sort_unstable();
assert_eq!(
recipients,
vec![&id("carol"), &id("dave")],
"Missed and unnamed members must both get a per-member copy"
);
for (_, payload) in &frames {
assert_eq!(
payload["message_id"].as_str(),
Some(logical),
"Re-issued copies must carry the logical id for cross-path dedup"
);
assert_eq!(
payload["ciphertext"].as_str(),
Some(base64_encode(b"broadcast-ct").as_str())
);
}
let tracked = internet_handle
.sent_messages()
.into_iter()
.filter(|m| m.content.starts_with(internal_prefixes::GROUP_MLS_MSG))
.all(|m| protocol.is_tracked_for_delivery(&m.id));
assert!(tracked, "Re-issued copies must be ACK-tracked");
let events = events.lock().unwrap();
let reissued = events
.iter()
.find_map(|e| match e {
Event::GroupMessageDeliveryReport {
missed_reissued, ..
} => Some(missed_reissued.clone()),
_ => None,
})
.expect("Expected GroupMessageDeliveryReport event");
let mut reissued_sorted = reissued;
reissued_sorted.sort_unstable();
assert_eq!(reissued_sorted, vec![id("carol"), id("dave")]);
}
#[test]
fn test_username_space_report_reissues_full_roster() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
grant_group_delivery_v3(&mut protocol);
let logical = "0d0d0d0d-1111-2222-3333-444444444444";
arm_fake_broadcast(
&mut protocol,
"grp-username-report",
logical,
&[&id("user123"), &id("bob"), &id("carol"), &id("dave")],
);
let report = serde_json::json!({
"type": "GroupMessageSent",
"group_id": "grp-username-report",
"message_id": logical,
"timestamp": "2026-08-11T00:00:00Z",
"delivered": ["bob"],
"pushed": ["carol"],
"missed": [],
})
.to_string();
protocol
.handle_relay_group_delivery_report(&report)
.unwrap();
let frames = grp_mls_frames(&internet_handle);
let mut recipients: Vec<String> = frames.iter().map(|(r, _)| r.clone()).collect();
recipients.sort_unstable();
let mut expected = vec![id("bob"), id("carol"), id("dave")];
expected.sort_unstable();
assert_eq!(
recipients, expected,
"Username-space claims must not subtract from the address roster — \
every non-self member gets a per-member copy"
);
}
#[test]
fn test_broadcast_report_unknown_or_mismatched_is_ignored() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
grant_group_delivery_v3(&mut protocol);
let logical = "0d0d0d0d-1111-2222-3333-444444444444";
arm_fake_broadcast(
&mut protocol,
"grp-guard",
logical,
&[&id("user123"), &id("bob")],
);
let unknown = serde_json::json!({
"group_id": "grp-guard",
"message_id": "ffffffff-1111-2222-3333-444444444444",
"delivered": [], "pushed": [], "missed": [],
})
.to_string();
protocol
.handle_relay_group_delivery_report(&unknown)
.unwrap();
assert!(protocol
.group_mesh
.relay_broadcast_pending
.contains_key(logical));
let mismatched = serde_json::json!({
"group_id": "some-other-group",
"message_id": logical,
"delivered": [&id("bob")], "pushed": [], "missed": [],
})
.to_string();
protocol
.handle_relay_group_delivery_report(&mismatched)
.unwrap();
assert!(
protocol
.group_mesh
.relay_broadcast_pending
.contains_key(logical),
"A mismatched report must not settle the tracker"
);
assert!(grp_mls_frames(&internet_handle).is_empty());
}
#[test]
fn test_lost_report_rebroadcasts_bounded_then_downgrades_per_member() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
grant_group_delivery_v3(&mut protocol);
let logical = "0e0e0e0e-1111-2222-3333-444444444444";
arm_fake_broadcast(
&mut protocol,
"grp-timeout",
logical,
&[&id("user123"), &id("bob"), &id("carol")],
);
let rewind = |protocol: &mut OfflineProtocol| {
protocol
.group_mesh
.relay_broadcast_pending
.get_mut(logical)
.unwrap()
.armed_at = chrono::Utc::now() - chrono::Duration::seconds(61);
};
let broadcast_frames = |handle: &MockTransport| {
handle
.sent_messages()
.into_iter()
.filter(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_BROADCAST)
})
.count()
};
protocol.process_relay_broadcast_report_timeouts();
assert_eq!(broadcast_frames(&internet_handle), 0);
rewind(&mut protocol);
protocol.process_relay_broadcast_report_timeouts();
assert_eq!(broadcast_frames(&internet_handle), 1);
let entry = protocol
.group_mesh
.relay_broadcast_pending
.get(logical)
.expect("still armed after re-send");
assert_eq!(entry.attempts, 2);
let resent = internet_handle
.sent_messages()
.into_iter()
.find(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_BROADCAST)
})
.unwrap();
let payload: serde_json::Value = serde_json::from_str(
resent
.content
.strip_prefix(internal_prefixes::GROUP_RELAY_BROADCAST)
.unwrap(),
)
.unwrap();
assert_eq!(
payload["message_id"].as_str(),
Some(logical),
"Re-sent broadcast must reuse the logical id — that is what keeps \
receiver and push dedup coherent across attempts"
);
rewind(&mut protocol);
protocol.process_relay_broadcast_report_timeouts();
assert_eq!(broadcast_frames(&internet_handle), 2);
assert_eq!(
protocol
.group_mesh
.relay_broadcast_pending
.get(logical)
.unwrap()
.attempts,
3
);
rewind(&mut protocol);
protocol.process_relay_broadcast_report_timeouts();
assert!(
!protocol
.group_mesh
.relay_broadcast_pending
.contains_key(logical),
"Exhausted broadcast must be settled by the downgrade"
);
assert_eq!(
broadcast_frames(&internet_handle),
2,
"No further broadcast attempts past the budget"
);
let frames = grp_mls_frames(&internet_handle);
let mut recipients: Vec<&str> = frames.iter().map(|(r, _)| r.as_str()).collect();
recipients.sort_unstable();
let mut expected = vec![id("bob"), id("carol")];
expected.sort_unstable();
assert_eq!(
recipients,
expected.iter().map(String::as_str).collect::<Vec<_>>(),
"Downgrade must fan out to the whole roster (minus self)"
);
for (_, payload) in &frames {
assert_eq!(payload["message_id"].as_str(), Some(logical));
}
}
#[test]
fn test_internet_drop_downgrades_pending_broadcasts_and_clears_capabilities() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(broadcast_enabled_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let wifi = MockTransport::new(TransportType::WiFiDirect);
wifi.start().unwrap();
let wifi_handle = wifi.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::WiFiDirect, Box::new(wifi));
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
grant_group_delivery_v3(&mut protocol);
let logical = "0f0f0f0f-1111-2222-3333-444444444444";
arm_fake_broadcast(
&mut protocol,
"grp-drop",
logical,
&[&id("user123"), &id("bob")],
);
protocol.group_mesh.internet_was_available = true;
protocol
.transport_manager_mut()
.remove_transport(TransportType::Internet);
protocol.check_relay_group_sync();
assert!(
protocol.group_mesh.relay_capabilities.is_empty(),
"Capabilities describe the dead connection and must be cleared"
);
assert!(
protocol.group_mesh.relay_broadcast_pending.is_empty(),
"Stranded broadcasts must be downgraded immediately"
);
let frames = grp_mls_frames(&wifi_handle);
assert_eq!(frames.len(), 1, "Per-member copy should go out over mesh");
assert_eq!(frames[0].0, id("bob"));
assert_eq!(frames[0].1["message_id"].as_str(), Some(logical));
}
#[test]
fn test_relay_broadcast_opt_out_forces_per_member_fanout() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut config = create_test_config();
config.group.relay_broadcast_enabled = false;
let mut protocol = OfflineProtocol::new(config).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Opt Out Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol")],
);
protocol.group_mesh.relay_synced.insert(group_id.clone());
grant_group_delivery_v3(&mut protocol);
let msg_ids = protocol
.send_group_message(&group_id, "hello opt-out", None, None)
.unwrap();
assert_eq!(msg_ids.len(), 2, "Expected per-member fan-out");
assert!(!internet_handle.sent_messages().iter().any(|m| m
.content
.starts_with(internal_prefixes::GROUP_RELAY_BROADCAST)));
}
#[test]
fn test_relay_capabilities_are_bounded() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let mut caps: Vec<String> = (0..200).map(|i| format!("cap-{}", i)).collect();
caps.push("x".repeat(4096));
protocol.set_relay_capabilities(caps);
assert!(protocol.group_mesh.relay_capabilities.len() <= 64);
assert!(!protocol
.group_mesh
.relay_capabilities
.iter()
.any(|c| c.len() > 128));
protocol.set_relay_capabilities(vec!["group_delivery_v3".to_string()]);
assert_eq!(protocol.group_mesh.relay_capabilities.len(), 1);
}
#[test]
fn test_grp_mls_msg_logical_id_emit_and_cross_path_dedup() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Logical Id Group");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let logical = "abcdabcd-1111-2222-3333-444444444444";
let encrypted = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.encrypt_for_group(&gid, b"hello logical").unwrap()
};
let ciphertext_b64 = base64_encode(&encrypted.ciphertext);
let payload = serde_json::json!({
"group_id": group_id,
"ciphertext": ciphertext_b64,
"epoch": encrypted.epoch,
"message_id": logical,
})
.to_string();
let msg1 = make_message(
&id("alice"),
&id("bob"),
&format!("{}{}", internal_prefixes::GROUP_MLS_MSG, payload),
);
let res = bob.handle_group_mls_msg(&msg1, &id("alice"), &payload);
assert!(matches!(res, InternalMessageResult::Consumed));
{
let events = events.lock().unwrap();
let received: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::GroupMessageReceived {
message_id,
content,
..
} => Some((message_id.clone(), content.clone())),
_ => None,
})
.collect();
assert_eq!(received.len(), 1);
assert_eq!(
received[0].0, logical,
"Emitted id must be the logical id, not the envelope id"
);
assert_eq!(received[0].1, "hello logical");
}
let msg2 = make_message(
&id("alice"),
&id("bob"),
&format!("{}{}", internal_prefixes::GROUP_MLS_MSG, payload),
);
let res2 = bob.handle_group_mls_msg(&msg2, &id("alice"), &payload);
assert!(
matches!(res2, InternalMessageResult::Consumed),
"Cross-path duplicate must be consumed (and re-ACKed by the receive loop)"
);
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&ciphertext_b64,
"2026-07-31T00:00:00Z",
logical,
None,
None,
);
let events = events.lock().unwrap();
let received_count = events
.iter()
.filter(|e| matches!(e, Event::GroupMessageReceived { .. }))
.count();
assert_eq!(
received_count, 1,
"The logical message must be delivered exactly once across paths"
);
}
#[test]
fn test_grp_mls_msg_failed_decrypt_does_not_poison_logical_id() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Poison Group");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let logical = "deadbeef-1111-2222-3333-444444444444";
let poison_payload = serde_json::json!({
"group_id": group_id,
"ciphertext": base64_encode(b"not real mls ciphertext"),
"epoch": 9,
"message_id": logical,
})
.to_string();
let poison_msg = make_message(
&id("mallory"),
&id("bob"),
&format!("{}{}", internal_prefixes::GROUP_MLS_MSG, poison_payload),
);
bob.handle_group_mls_msg(&poison_msg, &id("mallory"), &poison_payload);
assert!(
!bob.group_mesh.message_dedup.contains_key(logical),
"A failed decrypt must not mark the logical id as seen"
);
let encrypted = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.encrypt_for_group(&gid, b"the real message").unwrap()
};
let genuine_payload = serde_json::json!({
"group_id": group_id,
"ciphertext": base64_encode(&encrypted.ciphertext),
"epoch": encrypted.epoch,
"message_id": logical,
})
.to_string();
let genuine_msg = make_message(
&id("alice"),
&id("bob"),
&format!("{}{}", internal_prefixes::GROUP_MLS_MSG, genuine_payload),
);
let res = bob.handle_group_mls_msg(&genuine_msg, &id("alice"), &genuine_payload);
assert!(matches!(res, InternalMessageResult::Consumed));
let events = events.lock().unwrap();
assert!(
events.iter().any(|e| matches!(
e,
Event::GroupMessageReceived { message_id, content, .. }
if message_id == logical && content == "the real message"
)),
"Genuine copy must deliver despite the poison attempt"
);
}
#[test]
fn test_mesh_copy_sender_mismatch_rejected_releases_dedup_entry() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Mesh Mismatch Group");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let logical = "cafecafe-1111-2222-3333-444444444444";
let encrypted = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.encrypt_for_group(&gid, b"mesh body").unwrap()
};
let payload = serde_json::json!({
"group_id": group_id,
"ciphertext": base64_encode(&encrypted.ciphertext),
"epoch": encrypted.epoch,
"message_id": logical,
})
.to_string();
let wire = make_message(
&id("mallory"),
&id("bob"),
&format!("{}{}", internal_prefixes::GROUP_MLS_MSG, payload),
);
let res = bob.handle_group_mls_msg(&wire, &id("mallory"), &payload);
assert!(
matches!(res, InternalMessageResult::SecurityRejected),
"A wire sender that is not the MLS-authenticated author must be refused: got {:?}",
res
);
assert!(
!bob.group_mesh.message_dedup.contains_key(&wire.id.as_str()),
"A SecurityRejected mesh copy must not leave the envelope id marked, \
or the duplicate branch re-ACKs a replay as already delivered"
);
assert!(
!bob.group_mesh.message_dedup.contains_key(logical),
"The logical id is marked on a successful decrypt only"
);
let res = bob.handle_group_mls_msg(&wire, &id("mallory"), &payload);
assert!(
matches!(res, InternalMessageResult::Deferred),
"A replayed refusal must not be absorbed as an already-delivered \
duplicate (Consumed), which the receive loop ACKs: got {:?}",
res
);
assert!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.is_some_and(|q| !q.is_empty()),
"Deferred means buffered: the generation was spent by the refused \
decrypt, so custody stays with the sender"
);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupMessageReceived { .. })),
"A refused frame must never deliver"
);
}
#[test]
fn test_relay_copy_sender_mismatch_rejected_without_poisoning_logical_id() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Relay Mismatch Group");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let logical = "beefbeef-1111-2222-3333-444444444444";
let encrypted = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.encrypt_for_group(&gid, b"broadcast body").unwrap()
};
let ciphertext_b64 = base64_encode(&encrypted.ciphertext);
bob.handle_relay_group_message_with_mls(
&group_id,
"alice",
&ciphertext_b64,
"2026-08-11T00:00:00Z",
logical,
None,
None,
);
{
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupMessageReceived { .. })),
"A mis-attributed relay copy must not deliver"
);
}
assert!(
!bob.group_mesh.message_dedup.contains_key(logical),
"A SecurityRejected relay copy must not leave the logical id marked"
);
let payload = serde_json::json!({
"group_id": group_id,
"ciphertext": ciphertext_b64,
"epoch": encrypted.epoch,
"message_id": logical,
})
.to_string();
let reissue = make_message(
&id("alice"),
&id("bob"),
&format!("{}{}", internal_prefixes::GROUP_MLS_MSG, payload),
);
let res = bob.handle_group_mls_msg(&reissue, &id("alice"), &payload);
assert!(
matches!(res, InternalMessageResult::Deferred),
"The re-issue must be buffered un-ACKed (Deferred), not absorbed as \
an already-delivered duplicate (Consumed): got {:?}",
res
);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupMessageReceived { .. })),
"Nothing was deliverable — the generation was spent on the rejected copy"
);
}
#[test]
fn test_relay_copy_rejected_on_drain_does_not_poison_logical_id() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let logical = "d00dd00d-1111-2222-3333-444444444444";
let encrypted = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.encrypt_for_group(&gid, b"broadcast body").unwrap()
};
let ciphertext_b64 = base64_encode(&encrypted.ciphertext);
bob.handle_relay_group_message_with_mls(
&group_id,
"alice",
&ciphertext_b64,
"2026-08-11T00:00:00Z",
logical,
None,
None,
);
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(1),
"A relay copy that outran its Welcome must buffer"
);
assert!(
bob.group_mesh.message_dedup.contains_key(logical),
"A buffered entry keeps its mark for the pending lifetime"
);
bob.handle_group_mls_welcome("welcome-drain-reject", &id("alice"), &welcome_json);
assert!(
group_messages_received(&events).is_empty(),
"A mis-attributed relay copy must not deliver on the drain"
);
assert!(
!bob.group_mesh.message_dedup.contains_key(logical),
"A copy rejected on the drain must not leave the logical id marked"
);
let payload = serde_json::json!({
"group_id": group_id,
"ciphertext": ciphertext_b64,
"epoch": encrypted.epoch,
"message_id": logical,
})
.to_string();
let reissue = make_message(
&id("alice"),
&id("bob"),
&format!("{}{}", internal_prefixes::GROUP_MLS_MSG, payload),
);
let res = bob.handle_group_mls_msg(&reissue, &id("alice"), &payload);
assert!(
matches!(res, InternalMessageResult::Deferred),
"The re-issue must be judged on its own merits (Deferred), not absorbed \
as an already-delivered duplicate (Consumed): got {:?}",
res
);
assert!(
group_messages_received(&events).is_empty(),
"Nothing was deliverable — the generation was spent on the rejected copy"
);
}
#[test]
fn test_relay_plaintext_spoof_drop_does_not_poison_logical_id() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Relay Spoof Group");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let logical = "cafecafe-1111-2222-3333-444444444444";
bob.handle_relay_group_message_with_mls(
&group_id,
"mallory",
"not base64 !!!",
"2026-08-11T00:00:00Z",
logical,
None,
None,
);
{
let events = events.lock().unwrap();
assert!(
events.iter().any(|e| matches!(
e,
Event::SecurityWarning { reason_code, .. }
if *reason_code == crate::events::SecurityWarningCode::PlaintextReceiveRejected
)),
"The spoofed plaintext must be rejected with a security warning"
);
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupMessageReceived { .. })),
"The spoofed plaintext must not deliver"
);
}
assert!(
!bob.group_mesh.message_dedup.contains_key(logical),
"The spoof drop must not leave the attacker-chosen id marked"
);
let encrypted = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.encrypt_for_group(&gid, b"the real broadcast").unwrap()
};
let payload = serde_json::json!({
"group_id": group_id,
"ciphertext": base64_encode(&encrypted.ciphertext),
"epoch": encrypted.epoch,
"message_id": logical,
})
.to_string();
let genuine = make_message(
&id("alice"),
&id("bob"),
&format!("{}{}", internal_prefixes::GROUP_MLS_MSG, payload),
);
bob.handle_group_mls_msg(&genuine, &id("alice"), &payload);
let events = events.lock().unwrap();
assert!(
events.iter().any(|e| matches!(
e,
Event::GroupMessageReceived { message_id, content, .. }
if message_id == logical && content == "the real broadcast"
)),
"Genuine copy must deliver despite the spoofed frame naming its id"
);
}
#[test]
fn test_relay_broadcast_fallback_to_fanout() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Fanout Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol")],
);
protocol.group_mesh.relay_synced.remove(&group_id);
let msg_ids = protocol
.send_group_message(&group_id, "hello fanout", None, None)
.unwrap();
assert_eq!(
msg_ids.len(),
2,
"Fan-out should return one message ID per member"
);
}
#[test]
fn test_handle_relay_group_message_plaintext_passthrough() {
let (mut protocol, events) = setup_started_with_events();
protocol.group_mesh.members.insert(
"group:relay-plain".to_string(),
vec![id("user123"), id("alice")],
);
protocol.handle_relay_group_message_with_mls(
"group:relay-plain",
&id("alice"),
"Hello world! This is not base64",
"2026-03-13T00:00:00Z",
"msg-relay-001",
None,
None,
);
let events = events.lock().unwrap();
let received = events.iter().find(|e| {
matches!(e, Event::GroupMessageReceived { group_id, content, sender, .. }
if group_id == "group:relay-plain"
&& content == "Hello world! This is not base64"
&& sender == &id("alice"))
});
assert!(
received.is_some(),
"Plaintext relay message should be emitted as-is"
);
}
#[test]
fn test_handle_relay_group_message_mls_decrypt() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Relay MLS Test");
let encrypted = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.encrypt_for_group(&gid, b"Relay decrypted!")
.unwrap()
};
let ciphertext_b64 = base64_encode(&encrypted.ciphertext);
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&ciphertext_b64,
"2026-03-13T00:00:00Z",
"msg-relay-mls-001",
None,
None,
);
let events = bob_events.lock().unwrap();
let received = events.iter().find(|e| {
matches!(e, Event::GroupMessageReceived { group_id: gid, content, .. }
if gid == &group_id && content == "Relay decrypted!")
});
assert!(
received.is_some(),
"Relay MLS message should be decrypted and emitted"
);
}
#[test]
fn test_relay_group_plaintext_naming_mls_group_is_dropped_not_spoofed() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Spoof Test");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = bob_events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
"Spoofed message! definitely not base64",
"2026-03-13T00:00:00Z",
"msg-spoof-001",
None,
None,
);
let events = bob_events.lock().unwrap();
assert!(
!events.iter().any(|e| matches!(
e,
Event::GroupMessageReceived { content, .. }
if content == "Spoofed message! definitely not base64"
)),
"Plaintext naming an MLS-secured group must not be emitted as a group message"
);
assert!(
events.iter().any(|e| matches!(
e,
Event::SecurityWarning { reason_code, .. }
if *reason_code == crate::events::SecurityWarningCode::PlaintextReceiveRejected
)),
"Dropping a spoofed plaintext group message should emit a PlaintextReceiveRejected warning"
);
}
#[test]
fn test_handle_relay_group_message_mls_unavailable_drops_message() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.start().unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let raw_content = base64_encode(b"some ciphertext");
protocol.handle_relay_group_message_with_mls(
"group:no-mls",
"alice",
&raw_content,
"2026-03-13T00:00:00Z",
"msg-relay-no-mls",
None,
None,
);
let events = events.lock().unwrap();
let msg_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, Event::GroupMessageReceived { .. }))
.collect();
assert_eq!(
msg_events.len(),
0,
"Should drop message when MLS is unavailable, not emit raw ciphertext"
);
}
#[test]
fn test_relay_register_group_on_create() {
use offline_protocol_transport::mock::MockTransport;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
let internet_handle = internet.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
protocol.start().unwrap();
let info = protocol.create_group("Auto Register Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let register_frame = internet_handle
.sent_messages()
.iter()
.find(|m| {
m.content
.starts_with(internal_prefixes::GROUP_RELAY_REGISTER)
})
.map(|m| m.content.clone())
.expect("Registration frame should be sent on group creation with Internet available");
let payload: serde_json::Value = serde_json::from_str(
register_frame
.strip_prefix(internal_prefixes::GROUP_RELAY_REGISTER)
.unwrap(),
)
.unwrap();
assert_eq!(
payload.get("is_admin").and_then(|v| v.as_bool()),
Some(true),
"Creator's registration frame must carry is_admin=true"
);
assert!(
!protocol.group_mesh.relay_synced.contains(&group_id),
"Group must not be relay-synced before the relay acknowledges"
);
}
#[test]
fn test_is_internet_available() {
use offline_protocol_transport::mock::MockTransport;
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.start().unwrap();
assert!(!protocol.is_internet_available());
let internet = MockTransport::new(TransportType::Internet);
internet.start().unwrap();
protocol
.transport_manager_mut()
.add_transport(TransportType::Internet, Box::new(internet));
assert!(protocol.is_internet_available());
protocol
.transport_manager_mut()
.remove_transport(TransportType::Internet);
assert!(!protocol.is_internet_available());
}
#[test]
fn test_group_mls_send_total_failure_emits_partial_failure_event() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let info = protocol.create_group("Failure Event Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("bob"), id("carol")],
);
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let result = protocol.send_group_message(&group_id, "hello", None, None);
assert!(result.is_err(), "Total send failure should return Err");
let events = events.lock().unwrap();
let failure_event = events.iter().find(|e| {
matches!(
e,
Event::GroupMessagePartialFailure { group_id: gid, .. } if gid == &group_id
)
});
assert!(
failure_event.is_some(),
"Expected GroupMessagePartialFailure event on total failure"
);
if let Some(Event::GroupMessagePartialFailure {
failed_members,
succeeded_members,
..
}) = failure_event
{
assert!(
failed_members.contains(&id("bob")),
"bob should be in failed_members"
);
assert!(
failed_members.contains(&id("carol")),
"carol should be in failed_members"
);
assert_eq!(
failed_members.len(),
2,
"Should have exactly 2 failed members"
);
assert!(
succeeded_members.is_empty(),
"succeeded_members should be empty on total failure"
);
} else {
panic!("Event should be GroupMessagePartialFailure");
}
}
#[test]
fn test_epoch_fork_not_flagged_for_never_retried_expired_commits() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("Fork Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-9".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 0,
});
protocol.drain_pending_commits(&group_id);
assert!(
!protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Should not flag epoch fork for never-retried expired commits"
);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupEpochForkDetected { .. })),
"Should not emit GroupEpochForkDetected event"
);
}
#[test]
fn test_epoch_fork_flagged_for_retried_expired_commits() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("Fork Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-10".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 3,
});
protocol.drain_pending_commits(&group_id);
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Should flag epoch fork for retried expired commits"
);
let fork = &protocol.group_mesh.epoch_forks[&group_id];
assert_eq!(fork.group_id, group_id);
assert!(!fork.resolution_attempted);
let events = events.lock().unwrap();
assert!(
events.iter().any(
|e| matches!(e, Event::GroupEpochForkDetected { group_id: gid, .. } if gid == &group_id)
),
"Should emit GroupEpochForkDetected event"
);
}
#[test]
fn test_epoch_fork_not_duplicated_if_already_tracked() {
let (mut protocol, _events) = setup_with_events();
let info = protocol.create_group("Fork Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(1),
detected_at: Instant::now(),
resolution_attempted: false,
},
);
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-11".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 2,
});
protocol.drain_pending_commits(&group_id);
assert_eq!(
protocol.group_mesh.epoch_forks[&group_id].local_epoch,
Some(1)
);
}
#[test]
fn test_epoch_fork_max_entries_eviction() {
let (mut protocol, _events) = setup_with_events();
for i in 0..MAX_EPOCH_FORK_ENTRIES {
let gid = format!("group_{}", i);
protocol.group_mesh.epoch_forks.insert(
gid.clone(),
EpochForkState {
group_id: gid,
local_epoch: Some(i as u64),
detected_at: Instant::now(),
resolution_attempted: false,
},
);
}
assert_eq!(
protocol.group_mesh.epoch_forks.len(),
MAX_EPOCH_FORK_ENTRIES
);
let new_gid = "group_new".to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(new_gid.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-12".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 1,
});
protocol.drain_pending_commits(&new_gid);
assert!(protocol.group_mesh.epoch_forks.contains_key(&new_gid));
assert_eq!(
protocol.group_mesh.epoch_forks.len(),
MAX_EPOCH_FORK_ENTRIES
);
}
#[test]
fn test_epoch_fork_cleared_on_successful_commit() {
let (mut alice, bob, group_id) = setup_alice_bob_group("Fork Clear Test");
alice.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(1),
detected_at: Instant::now(),
resolution_attempted: false,
},
);
let alice_update = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.update_keys(&gid).unwrap()
};
{
let mls = bob.mls_manager_for_testing().read().unwrap();
let encrypted = offline_protocol_mls::EncryptedMessage {
group_id: offline_protocol_mls::GroupId::new(&group_id).unwrap(),
message_type: offline_protocol_mls::MlsMessageType::Commit,
epoch: alice_update.epoch,
ciphertext: alice_update.ciphertext.clone(),
sender_id: id("alice"),
timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
};
mls.decrypt_from_group(&encrypted, &id("alice")).unwrap();
}
let bob_commit = {
let mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.update_keys(&gid).unwrap()
};
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::KeyUpdate,
ciphertext: base64_encode(&bob_commit.ciphertext),
epoch: bob_commit.epoch,
affected_member: None,
role: None,
};
let data = serde_json::to_string(&commit_payload).unwrap();
alice.handle_group_mls_commit("commit-fork-clear-1", &id("bob"), &data);
assert!(
!alice.group_mesh.epoch_forks.contains_key(&group_id),
"Fork state should be cleared after successful commit"
);
}
#[test]
fn test_epoch_fork_resolution_by_leader() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("Resolution Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS + 5);
protocol.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(1),
detected_at: past,
resolution_attempted: false,
},
);
protocol.check_epoch_forks();
assert!(
!protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Fork should be removed after successful resolution by leader"
);
let events = events.lock().unwrap();
assert!(
events.iter().any(
|e| matches!(e, Event::GroupEpochForkResolved { group_id: gid, .. } if gid == &group_id)
),
"Should emit GroupEpochForkResolved event"
);
}
#[test]
fn test_epoch_fork_resolution_skipped_for_non_leader() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config_for_user("zoe")).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let _info = protocol.create_group("Non-Leader Test").unwrap();
let fake_group_id = "fake_fork_group".to_string();
protocol
.group_mesh
.members
.insert(fake_group_id.clone(), vec![id("alice"), id("zoe")]);
let past = Instant::now() - StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS + 5);
protocol.group_mesh.epoch_forks.insert(
fake_group_id.clone(),
EpochForkState {
group_id: fake_group_id.clone(),
local_epoch: Some(1),
detected_at: past,
resolution_attempted: false,
},
);
protocol.check_epoch_forks();
assert!(
protocol.group_mesh.epoch_forks.contains_key(&fake_group_id),
"Fork should still exist for non-leader"
);
assert!(
protocol.group_mesh.epoch_forks[&fake_group_id].resolution_attempted,
"Fork should be marked as resolution_attempted even for non-leader"
);
}
#[test]
fn test_epoch_fork_not_resolved_before_delay() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("Delay Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(1),
detected_at: Instant::now(),
resolution_attempted: false,
},
);
protocol.check_epoch_forks();
assert!(protocol.group_mesh.epoch_forks.contains_key(&group_id));
assert!(!protocol.group_mesh.epoch_forks[&group_id].resolution_attempted);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupEpochForkResolved { .. })),
"Should not emit GroupEpochForkResolved before delay"
);
}
#[test]
fn test_epoch_fork_stale_entries_cleaned_up() {
let (mut protocol, _events) = setup_with_events();
let very_old = Instant::now() - StdDuration::from_secs(400);
protocol.group_mesh.epoch_forks.insert(
"stale_group".to_string(),
EpochForkState {
group_id: "stale_group".to_string(),
local_epoch: Some(1),
detected_at: very_old,
resolution_attempted: true,
},
);
protocol.check_epoch_forks();
assert!(
!protocol.group_mesh.epoch_forks.contains_key("stale_group"),
"Stale fork entries (>5 min) should be cleaned up"
);
}
#[test]
fn test_leave_election_cleared_when_member_already_removed() {
let (mut protocol, _events) = setup_with_events();
let info = protocol.create_group("Leave Election Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS + 5);
let key = (group_id.clone(), id("bob"));
protocol.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: id("bob"),
received_at: past,
last_attempt_at: None,
},
);
protocol.check_leave_election_timeouts();
assert!(
!protocol
.group_mesh
.pending_leave_elections
.contains_key(&key),
"Election should be cleared when leaving member is no longer in group"
);
}
#[test]
fn test_leave_election_timeout_re_elects_self() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Re-election Test");
let past = Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS + 5);
let key = (group_id.clone(), id("bob"));
alice.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: id("bob"),
received_at: past,
last_attempt_at: None,
},
);
alice.check_leave_election_timeouts();
assert!(
!alice.group_mesh.pending_leave_elections.contains_key(&key),
"Election should be cleared after successful remove"
);
let members = alice.refresh_group_members(&group_id).unwrap();
assert!(
!members.contains(&id("bob")),
"Bob should be removed from group after re-election"
);
}
#[test]
fn test_leave_election_not_triggered_before_timeout() {
let (mut protocol, _events) = setup_with_events();
let info = protocol.create_group("Timeout Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let key = (group_id.clone(), id("bob"));
protocol.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: id("bob"),
received_at: Instant::now(), last_attempt_at: None,
},
);
protocol.check_leave_election_timeouts();
assert!(
protocol
.group_mesh
.pending_leave_elections
.contains_key(&key),
"Election should remain pending before timeout"
);
}
#[test]
fn test_pending_commit_retry_count_incremented() {
let (mut protocol, _events) = setup_with_events();
let info = protocol.create_group("Retry Count Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.buffer_pending_commit(&group_id, "mid-fake", &id("bob"), "fake-data");
let pending = protocol.group_mesh.pending_commits.get(&group_id).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].retry_count, 0, "Initial retry_count should be 0");
}
#[test]
fn test_key_update_commit_type_serialization() {
let payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: "test-group".to_string(),
commit_type: GroupCommitType::KeyUpdate,
ciphertext: "abc".to_string(),
epoch: 5,
affected_member: None,
role: None,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(
json.contains("\"keyupdate\""),
"KeyUpdate should serialize as 'keyupdate'"
);
let deserialized: GroupMlsCommitPayload = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.commit_type, GroupCommitType::KeyUpdate);
assert!(deserialized.affected_member.is_none());
}
#[test]
fn test_epoch_fork_detected_via_periodic_cleanup_without_drain() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("Periodic Fork Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-13".to_string(),
data: "fake-commit".to_string(),
buffered_at: past,
retry_count: 2,
});
protocol.cleanup_group_message_dedup();
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Fork should be detected during periodic cleanup when retried commits expire"
);
let events = events.lock().unwrap();
assert!(
events.iter().any(
|e| matches!(e, Event::GroupEpochForkDetected { group_id: gid, .. } if gid == &group_id)
),
"Should emit GroupEpochForkDetected during periodic cleanup"
);
}
#[test]
fn test_epoch_fork_not_flagged_via_cleanup_for_never_retried() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("No Fork Cleanup").unwrap();
let group_id = info.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-14".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 0, });
protocol.cleanup_group_message_dedup();
assert!(
!protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Never-retried expired commits should not trigger fork detection during cleanup"
);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupEpochForkDetected { .. })),
"No fork event for never-retried expired commits"
);
}
#[test]
fn test_epoch_fork_cleanup_does_not_duplicate_existing_fork() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("No Dup Cleanup").unwrap();
let group_id = info.group_id.as_str().to_string();
let original_detected_at = Instant::now() - StdDuration::from_secs(30);
protocol.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(42),
detected_at: original_detected_at,
resolution_attempted: false,
},
);
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-15".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 3,
});
protocol.cleanup_group_message_dedup();
assert_eq!(
protocol.group_mesh.epoch_forks[&group_id].local_epoch,
Some(42)
);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupEpochForkDetected { .. })),
"Should not emit duplicate fork detection event"
);
}
#[test]
fn test_epoch_fork_resolution_includes_failed_members() {
let peer_label = (0..64)
.map(|n| format!("peer-{n}"))
.find(|label| id(label) > id("alice"))
.expect("a peer sorting after us");
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user(&peer_label)).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group("Fork Send Fail").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.add_group_member(&gid, &id(&peer_label), &bob_kp.key_package_data)
.unwrap();
}
alice.refresh_group_members(&group_id).unwrap();
let _ = alice.stop();
let past = Instant::now() - StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS + 5);
alice.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(1),
detected_at: past,
resolution_attempted: false,
},
);
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
alice.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
alice.check_epoch_forks();
assert!(
!alice.group_mesh.epoch_forks.contains_key(&group_id),
"Fork should be cleaned up after resolution attempt"
);
let events = events.lock().unwrap();
let resolved = events.iter().find(
|e| matches!(e, Event::GroupEpochForkResolved { group_id: gid, .. } if gid == &group_id),
);
assert!(resolved.is_some(), "Should emit GroupEpochForkResolved");
if let Some(Event::GroupEpochForkResolved { failed_members, .. }) = resolved {
assert!(
failed_members.contains(&id(&peer_label)),
"bob should be in failed_members when protocol is stopped"
);
assert!(
!failed_members.contains(&id("alice")),
"self should not be in failed_members"
);
} else {
panic!("Expected GroupEpochForkResolved event");
}
}
#[test]
fn test_epoch_fork_resolution_no_failed_members_when_all_succeed() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("All Succeed Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS + 5);
protocol.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(1),
detected_at: past,
resolution_attempted: false,
},
);
protocol.check_epoch_forks();
let events = events.lock().unwrap();
let resolved = events.iter().find(
|e| matches!(e, Event::GroupEpochForkResolved { group_id: gid, .. } if gid == &group_id),
);
assert!(resolved.is_some(), "Should emit resolved event");
if let Some(Event::GroupEpochForkResolved { failed_members, .. }) = resolved {
assert!(
failed_members.is_empty(),
"No failed members when all sends succeed"
);
}
}
#[test]
fn test_epoch_fork_update_keys_failure_leaves_resolution_attempted() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let fake_group_id = "group:nonexistent-for-update".to_string();
protocol.group_mesh.members.insert(
fake_group_id.clone(),
vec![id("user123")], );
let past = Instant::now() - StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS + 5);
protocol.group_mesh.epoch_forks.insert(
fake_group_id.clone(),
EpochForkState {
group_id: fake_group_id.clone(),
local_epoch: Some(1),
detected_at: past,
resolution_attempted: false,
},
);
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
protocol.check_epoch_forks();
assert!(
protocol.group_mesh.epoch_forks.contains_key(&fake_group_id),
"Fork should remain when update_keys fails"
);
assert!(
protocol.group_mesh.epoch_forks[&fake_group_id].resolution_attempted,
"resolution_attempted should be true after failed update_keys"
);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupEpochForkResolved { .. })),
"Should not emit resolved event when update_keys fails"
);
}
#[test]
fn test_epoch_fork_mls_unavailable_resets_resolution_attempted() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let group_id = "group:no-mls".to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec!["user123".to_string()]);
let past = Instant::now() - StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS + 5);
protocol.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(0),
detected_at: past,
resolution_attempted: false,
},
);
protocol.check_epoch_forks();
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Fork should remain when MLS is unavailable"
);
assert!(
!protocol.group_mesh.epoch_forks[&group_id].resolution_attempted,
"resolution_attempted should be reset to false when MLS is unavailable"
);
}
#[test]
fn test_epoch_fork_multiple_concurrent_groups() {
let (mut protocol, events) = setup_with_events();
let info_a = protocol.create_group("Fork Group A").unwrap();
let info_b = protocol.create_group("Fork Group B").unwrap();
let group_a = info_a.group_id.as_str().to_string();
let group_b = info_b.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
for gid in [&group_a, &group_b] {
protocol
.group_mesh
.pending_commits
.entry(gid.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-16".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 1,
});
}
protocol.cleanup_group_message_dedup();
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_a),
"Group A should have fork detected"
);
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_b),
"Group B should have fork detected"
);
let events = events.lock().unwrap();
let fork_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, Event::GroupEpochForkDetected { .. }))
.collect();
assert_eq!(
fork_events.len(),
2,
"Should emit fork detection for both groups"
);
}
#[test]
fn test_epoch_fork_multiple_groups_resolve_independently() {
let (mut protocol, events) = setup_with_events();
let info_a = protocol.create_group("Resolve A").unwrap();
let info_b = protocol.create_group("Resolve B").unwrap();
let group_a = info_a.group_id.as_str().to_string();
let group_b = info_b.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS + 5);
protocol.group_mesh.epoch_forks.insert(
group_a.clone(),
EpochForkState {
group_id: group_a.clone(),
local_epoch: Some(1),
detected_at: past,
resolution_attempted: false,
},
);
protocol.group_mesh.epoch_forks.insert(
group_b.clone(),
EpochForkState {
group_id: group_b.clone(),
local_epoch: Some(2),
detected_at: Instant::now(),
resolution_attempted: false,
},
);
protocol.check_epoch_forks();
assert!(
!protocol.group_mesh.epoch_forks.contains_key(&group_a),
"Group A fork should be resolved"
);
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_b),
"Group B fork should still be pending"
);
assert!(
!protocol.group_mesh.epoch_forks[&group_b].resolution_attempted,
"Group B resolution should not have been attempted"
);
let events = events.lock().unwrap();
let resolved_count = events
.iter()
.filter(|e| matches!(e, Event::GroupEpochForkResolved { .. }))
.count();
assert_eq!(resolved_count, 1, "Only group A should have been resolved");
}
#[test]
fn test_epoch_fork_stale_cleanup_removes_old_attempted_and_unattempted() {
let (mut protocol, _events) = setup_with_events();
let very_old = Instant::now() - StdDuration::from_secs(400);
protocol.group_mesh.epoch_forks.insert(
"stale_attempted".to_string(),
EpochForkState {
group_id: "stale_attempted".to_string(),
local_epoch: Some(1),
detected_at: very_old,
resolution_attempted: true,
},
);
protocol.group_mesh.epoch_forks.insert(
"stale_unattempted".to_string(),
EpochForkState {
group_id: "stale_unattempted".to_string(),
local_epoch: Some(2),
detected_at: very_old,
resolution_attempted: false,
},
);
protocol.group_mesh.epoch_forks.insert(
"recent_fork".to_string(),
EpochForkState {
group_id: "recent_fork".to_string(),
local_epoch: Some(3),
detected_at: Instant::now(),
resolution_attempted: false,
},
);
protocol.check_epoch_forks();
assert!(
!protocol
.group_mesh
.epoch_forks
.contains_key("stale_attempted"),
"Old attempted fork should be cleaned up"
);
assert!(
!protocol
.group_mesh
.epoch_forks
.contains_key("stale_unattempted"),
"Old unattempted fork should be cleaned up"
);
assert!(
protocol.group_mesh.epoch_forks.contains_key("recent_fork"),
"Recent fork should survive cleanup"
);
}
#[test]
fn test_epoch_fork_detection_with_mls_unavailable_uses_none_epoch() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let group_id = "group:no-mls-fork".to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(PendingCommit {
sender: "bob".to_string(),
message_id: "test-mid-17".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 1,
});
protocol.drain_pending_commits(&group_id);
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Fork should be detected even when MLS is unavailable"
);
assert_eq!(
protocol.group_mesh.epoch_forks[&group_id].local_epoch, None,
"local_epoch should be None when MLS is unavailable"
);
}
#[test]
fn test_pending_leave_elections_size_cap_eviction() {
let (mut protocol, _events) = setup_with_events();
for i in 0..MAX_PENDING_LEAVE_ELECTIONS {
let gid = format!("group_{}", i);
let member = format!("member_{}", i);
let key = (gid.clone(), member.clone());
protocol.group_mesh.pending_leave_elections.insert(
key,
PendingLeaveElection {
group_id: gid,
leaving_member: member,
received_at: Instant::now(),
last_attempt_at: None,
},
);
}
assert_eq!(
protocol.group_mesh.pending_leave_elections.len(),
MAX_PENDING_LEAVE_ELECTIONS
);
let new_group = "group:overflow".to_string();
let new_leaver = "new_leaver";
protocol.group_mesh.members.insert(
new_group.clone(),
vec![
id("alice"), "test_user".to_string(),
new_leaver.to_string(),
],
);
let leave_payload = GroupMlsLeavePayload {
group_id: new_group.clone(),
leaving_member: new_leaver.to_string(),
};
let data = serde_json::to_string(&leave_payload).unwrap();
protocol.handle_group_mls_leave("leave-evict-1", new_leaver, &data);
assert!(
protocol.group_mesh.pending_leave_elections.len() <= MAX_PENDING_LEAVE_ELECTIONS,
"Pending leave elections should not exceed MAX_PENDING_LEAVE_ELECTIONS"
);
let new_key = (new_group.clone(), new_leaver.to_string());
assert!(
protocol
.group_mesh
.pending_leave_elections
.contains_key(&new_key),
"New leave election should be inserted after eviction"
);
}
#[test]
fn test_leave_election_circuit_breaker_max_lifetime() {
let (mut protocol, _events) = setup_with_events();
let group_id = "group:circuit-breaker".to_string();
let leaving_member = "leaver";
let very_old = Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_MAX_LIFETIME_SECS + 10);
let key = (group_id.clone(), leaving_member.to_string());
protocol.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: leaving_member.to_string(),
received_at: very_old,
last_attempt_at: None,
},
);
protocol.group_mesh.members.insert(
group_id.clone(),
vec![
"test_user".to_string(),
leaving_member.to_string(),
"other".to_string(),
],
);
protocol.check_leave_election_timeouts();
assert!(
!protocol
.group_mesh
.pending_leave_elections
.contains_key(&key),
"Leave election should be abandoned after max lifetime"
);
}
#[test]
fn test_non_key_update_commit_does_not_clear_fork_state() {
let (mut alice, bob, group_id) = setup_alice_bob_group("Fork Preserve Test");
alice.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(1),
detected_at: Instant::now(),
resolution_attempted: false,
},
);
let bob_update = {
let mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.update_keys(&gid).unwrap()
};
let add_commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(&bob_update.ciphertext),
epoch: bob_update.epoch,
affected_member: Some(id("charlie")),
role: None,
};
let data = serde_json::to_string(&add_commit_payload).unwrap();
alice.handle_group_mls_commit("commit-add-no-clear-1", &id("bob"), &data);
assert!(
alice.group_mesh.epoch_forks.contains_key(&group_id),
"Fork state should NOT be cleared by a non-KeyUpdate commit"
);
}
#[test]
fn test_key_update_commit_clears_fork_state() {
let (mut alice, bob, group_id) = setup_alice_bob_group("Fork Clear KU Test");
alice.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(1),
detected_at: Instant::now(),
resolution_attempted: false,
},
);
let bob_update = {
let mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.update_keys(&gid).unwrap()
};
let ku_commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::KeyUpdate,
ciphertext: base64_encode(&bob_update.ciphertext),
epoch: bob_update.epoch,
affected_member: None,
role: None,
};
let data = serde_json::to_string(&ku_commit_payload).unwrap();
alice.handle_group_mls_commit("commit-ku-clear-1", &id("bob"), &data);
assert!(
!alice.group_mesh.epoch_forks.contains_key(&group_id),
"Fork state SHOULD be cleared by a KeyUpdate commit"
);
}
#[test]
fn test_epoch_fork_detection_event_has_none_epoch_when_mls_unavailable() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let group_id = "group:event-none-epoch".to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default()
.push_back(PendingCommit {
sender: "bob".to_string(),
message_id: "test-mid-18".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 1,
});
protocol.drain_pending_commits(&group_id);
let events = events.lock().unwrap();
let fork_event = events.iter().find(|e| {
matches!(e, Event::GroupEpochForkDetected { group_id: gid, local_epoch: None, .. } if gid == &group_id)
});
assert!(
fork_event.is_some(),
"Fork detection event should have local_epoch=None when MLS is unavailable"
);
}
#[test]
fn test_epoch_fork_periodic_cleanup_multiple_groups_mixed() {
let (mut protocol, events) = setup_with_events();
let info_fork = protocol.create_group("Will Fork").unwrap();
let info_nofork = protocol.create_group("No Fork").unwrap();
let group_fork = info_fork.group_id.as_str().to_string();
let group_nofork = info_nofork.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
protocol
.group_mesh
.pending_commits
.entry(group_fork.clone())
.or_default()
.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-19".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 2,
});
protocol
.group_mesh
.pending_commits
.entry(group_nofork.clone())
.or_default()
.push_back(PendingCommit {
sender: id("carol"),
message_id: "test-mid-20".to_string(),
data: "fake".to_string(),
buffered_at: past,
retry_count: 0,
});
protocol.cleanup_group_message_dedup();
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_fork),
"Group with retried expired commits should have fork detected"
);
assert!(
!protocol.group_mesh.epoch_forks.contains_key(&group_nofork),
"Group with only never-retried expired commits should not have fork"
);
let events = events.lock().unwrap();
let fork_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, Event::GroupEpochForkDetected { .. }))
.collect();
assert_eq!(
fork_events.len(),
1,
"Only one fork event should be emitted"
);
}
#[test]
fn test_leave_election_cooldown_prevents_per_tick_spam() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Cooldown Test");
let past = Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS + 5);
let key = (group_id.clone(), id("bob"));
alice.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: id("bob"),
received_at: past,
last_attempt_at: Some(Instant::now()), },
);
alice.check_leave_election_timeouts();
assert!(
alice.group_mesh.pending_leave_elections.contains_key(&key),
"Election should remain pending during cooldown"
);
let members = alice.refresh_group_members(&group_id).unwrap();
assert!(
members.contains(&id("bob")),
"Bob should still be in group — cooldown should prevent remove attempt"
);
}
#[test]
fn test_leave_election_proceeds_after_cooldown_expires() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Cooldown Expiry Test");
let past = Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS + 5);
let key = (group_id.clone(), id("bob"));
let old_attempt =
Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_ATTEMPT_COOLDOWN_SECS + 5);
alice.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: id("bob"),
received_at: past,
last_attempt_at: Some(old_attempt),
},
);
alice.check_leave_election_timeouts();
assert!(
!alice.group_mesh.pending_leave_elections.contains_key(&key),
"Election should be cleared after successful remove (cooldown expired)"
);
let members = alice.refresh_group_members(&group_id).unwrap();
assert!(
!members.contains(&id("bob")),
"Bob should be removed after cooldown-expired re-election"
);
}
#[test]
fn test_leave_election_staggered_candidate_selection() {
let (mut protocol, _events) = setup_with_events();
let group_id = "group:staggered".to_string();
let leaving = "leaver";
let self_id = id("user123");
let pool: Vec<String> = (0..64).map(|n| format!("peer-{n}")).collect();
let ahead: Vec<String> = pool
.iter()
.map(|label| id(label))
.filter(|m| m < &self_id)
.take(3)
.collect();
assert!(
!ahead.is_empty(),
"need at least one member sorting before us for a reachable interval"
);
let self_interval = ahead.len() as u64;
let other_interval = self_interval - 1;
let mut members: Vec<String> = ahead.clone();
members.push(self_id.clone());
members.push(leaving.to_string());
protocol
.group_mesh
.members
.insert(group_id.clone(), members);
let key = (group_id.clone(), leaving.to_string());
let past_1 =
Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS * other_interval + 5);
protocol.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: leaving.to_string(),
received_at: past_1,
last_attempt_at: None,
},
);
protocol.check_leave_election_timeouts();
assert!(
protocol
.group_mesh
.pending_leave_elections
.contains_key(&key),
"Election must remain pending — we are not the candidate at this interval"
);
let past_3 =
Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS * self_interval + 5);
protocol
.group_mesh
.pending_leave_elections
.get_mut(&key)
.unwrap()
.received_at = past_3;
protocol
.group_mesh
.pending_leave_elections
.get_mut(&key)
.unwrap()
.last_attempt_at = None; protocol.check_leave_election_timeouts();
assert!(
protocol
.group_mesh
.pending_leave_elections
.contains_key(&key),
"Election should remain pending after failed remove at interval 3"
);
assert!(
protocol.group_mesh.pending_leave_elections[&key]
.last_attempt_at
.is_some(),
"Cooldown should be set — user123 was selected and attempted remove at interval 3"
);
}
#[test]
fn test_leave_election_staggered_with_real_mls_group() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Staggered Real Test");
alice
.group_mesh
.members
.get_mut(&group_id)
.unwrap()
.push(id("charlie"));
let key = (group_id.clone(), id("charlie"));
let past = Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS + 5);
alice.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: id("charlie"),
received_at: past,
last_attempt_at: None,
},
);
alice.check_leave_election_timeouts();
assert!(
!alice.group_mesh.pending_leave_elections.contains_key(&key),
"Election should be cleaned up — charlie is not in MLS group"
);
}
#[test]
fn test_handle_group_mls_leave_records_pending_election_for_non_elected() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config_for_user("zoe")).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let group_id = "group:leave-else-branch".to_string();
let self_id = id("zoe");
let elected = (0..64)
.map(|n| id(&format!("peer-{n}")))
.find(|m| m < &self_id)
.expect("a peer sorting before us");
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![elected, id("bob"), self_id]);
let leave_payload = GroupMlsLeavePayload {
group_id: group_id.clone(),
leaving_member: id("bob"),
};
let data = serde_json::to_string(&leave_payload).unwrap();
protocol.handle_group_mls_leave("leave-else-1", &id("bob"), &data);
let key = (group_id.clone(), id("bob"));
assert!(
protocol
.group_mesh
.pending_leave_elections
.contains_key(&key),
"Non-elected member should record a PendingLeaveElection"
);
let election = &protocol.group_mesh.pending_leave_elections[&key];
assert_eq!(election.group_id, group_id);
assert_eq!(election.leaving_member, id("bob"));
assert!(election.last_attempt_at.is_none());
}
#[test]
fn test_handle_group_mls_leave_elected_does_not_record_election() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Elected No Record");
let leave_payload = GroupMlsLeavePayload {
group_id: group_id.clone(),
leaving_member: id("bob"),
};
let data = serde_json::to_string(&leave_payload).unwrap();
alice.handle_group_mls_leave("leave-elected-1", &id("bob"), &data);
let key = (group_id.clone(), id("bob"));
assert!(
!alice.group_mesh.pending_leave_elections.contains_key(&key),
"Elected member should NOT have a pending election"
);
}
#[test]
fn test_sync_groups_to_relay_refreshes_from_mls() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Relay Refresh Test");
alice
.group_mesh
.members
.insert(group_id.clone(), vec![id("alice")]);
alice.group_mesh.relay_synced.remove(&group_id);
alice.config.group.relay_enabled = true;
alice.group_mesh.internet_was_available = false;
let refreshed = alice.refresh_group_members(&group_id).unwrap();
assert!(
refreshed.contains(&id("alice")) && refreshed.contains(&id("bob")),
"refresh_group_members should return MLS-authoritative membership — got {:?}",
refreshed
);
let cached = alice.group_mesh.members.get(&group_id).unwrap();
assert!(
cached.contains(&id("alice")) && cached.contains(&id("bob")),
"Cached membership should be updated from MLS — got {:?}",
cached
);
}
#[test]
fn test_drain_pending_commits_mixed_retried_and_non_retried_expired() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("Mixed Drain Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let past = Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10);
let buf = protocol
.group_mesh
.pending_commits
.entry(group_id.clone())
.or_default();
buf.push_back(PendingCommit {
sender: id("alice"),
message_id: "test-mid-21".to_string(),
data: "fake-1".to_string(),
buffered_at: past,
retry_count: 0,
});
buf.push_back(PendingCommit {
sender: id("bob"),
message_id: "test-mid-22".to_string(),
data: "fake-2".to_string(),
buffered_at: past,
retry_count: 2,
});
buf.push_back(PendingCommit {
sender: id("carol"),
message_id: "test-mid-23".to_string(),
data: "fake-3".to_string(),
buffered_at: Instant::now(),
retry_count: 0,
});
protocol.drain_pending_commits(&group_id);
assert!(
protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Fork should be detected when ANY expired commit has retry_count > 0"
);
let events = events.lock().unwrap();
assert!(
events.iter().any(
|e| matches!(e, Event::GroupEpochForkDetected { group_id: gid, .. } if gid == &group_id)
),
"Should emit fork detection event for mixed group"
);
}
#[test]
fn test_leave_election_remove_failure_keeps_election_pending() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let group_id = "group:remove-fail".to_string();
let leaver = "leaver";
protocol.group_mesh.members.insert(
group_id.clone(),
vec![
leaver.to_string(),
id("user123"), ],
);
let past = Instant::now() - StdDuration::from_secs(LEAVE_ELECTION_TIMEOUT_SECS + 5);
let key = (group_id.clone(), leaver.to_string());
protocol.group_mesh.pending_leave_elections.insert(
key.clone(),
PendingLeaveElection {
group_id: group_id.clone(),
leaving_member: leaver.to_string(),
received_at: past,
last_attempt_at: None,
},
);
protocol.check_leave_election_timeouts();
assert!(
protocol
.group_mesh
.pending_leave_elections
.contains_key(&key),
"Election should remain pending after remove_from_group failure"
);
let election = &protocol.group_mesh.pending_leave_elections[&key];
assert!(
election.last_attempt_at.is_some(),
"last_attempt_at should be set after failed attempt"
);
}
#[test]
fn test_invite_to_group_consumes_key_package() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
alice.start().unwrap();
let group_info = alice.create_group("Consume KP Test").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_mls = crate::test_identity::manager_for("bob", storage_b);
let bob_kp = bob_mls.generate_key_package().unwrap();
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice.invite_to_group(&group_id, &id("bob")).unwrap();
assert!(
!alice.pending_key_packages.contains_key(&id("bob")),
"Key package should be removed after invite_to_group consumes it"
);
}
#[test]
fn test_invite_same_peer_to_two_groups_needs_fresh_key_package() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
alice.start().unwrap();
let group1 = alice.create_group("Group One").unwrap();
let group1_id = group1.group_id.as_str().to_string();
let group2 = alice.create_group("Group Two").unwrap();
let group2_id = group2.group_id.as_str().to_string();
let bob_mls = crate::test_identity::manager_for("bob", storage_b);
let bob_kp = bob_mls.generate_key_package().unwrap();
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice.invite_to_group(&group1_id, &id("bob")).unwrap();
let result = alice.invite_to_group(&group2_id, &id("bob"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("No key package"),
"Expected clean 'No key package' error, got: {}",
err_msg
);
let bob_kp2 = bob_mls.generate_key_package().unwrap();
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp2.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice
.invite_to_group(&group2_id, &id("bob"))
.expect("Second invite should succeed with fresh key package");
let g1_members = alice.group_mesh.members.get(&group1_id).unwrap();
let g2_members = alice.group_mesh.members.get(&group2_id).unwrap();
assert!(g1_members.contains(&id("bob")));
assert!(g2_members.contains(&id("bob")));
}
#[test]
fn test_epoch_fork_cancelled_when_epoch_advanced_since_detection() {
let (mut protocol, events) = setup_with_events();
let info = protocol.create_group("Cancel Test").unwrap();
let group_id = info.group_id.as_str().to_string();
{
let guard = protocol.read_mls_guard().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
guard.update_keys(&gid).expect("key update should succeed");
let info = guard.get_group_info(&gid).unwrap().unwrap();
assert!(
info.epoch > 0,
"Epoch should have advanced after key update"
);
}
let past = Instant::now() - StdDuration::from_secs(EPOCH_FORK_RESOLUTION_DELAY_SECS + 5);
protocol.group_mesh.epoch_forks.insert(
group_id.clone(),
EpochForkState {
group_id: group_id.clone(),
local_epoch: Some(0),
detected_at: past,
resolution_attempted: false,
},
);
protocol.check_epoch_forks();
assert!(
!protocol.group_mesh.epoch_forks.contains_key(&group_id),
"Fork should be auto-cancelled when epoch advanced since detection"
);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupEpochForkResolved { .. })),
"Cancelled fork should not emit GroupEpochForkResolved"
);
}
#[test]
fn test_relay_group_message_dedup() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Dedup Relay");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let ciphertext = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let enc = mls.encrypt_for_group(&gid, b"hello relay dedup").unwrap();
base64_encode(&enc.ciphertext)
};
let msg_id = "relay-dedup-msg-1";
let ts = chrono::Utc::now().to_rfc3339();
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&ciphertext,
&ts,
msg_id,
None,
None,
);
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&ciphertext,
&ts,
msg_id,
None,
None,
);
let evts = events.lock().unwrap();
let msg_events: Vec<_> = evts
.iter()
.filter(|e| matches!(e, Event::GroupMessageReceived { .. }))
.collect();
assert_eq!(
msg_events.len(),
1,
"Duplicate relay group message should be deduplicated"
);
}
#[test]
fn test_duplicate_welcome_ignored() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Dup Welcome");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let welcome_payload = serde_json::json!({
"group_id": group_id,
"welcome_data": base64_encode(b"fake-welcome-data"),
"group_name": Some("Dup Welcome"),
});
let data = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&welcome_payload).unwrap()
);
let json_data = &data[internal_prefixes::GROUP_MLS_WELCOME.len()..];
bob.handle_group_mls_welcome("welcome-dup-1", &id("alice"), json_data);
let evts = events.lock().unwrap();
let add_events: Vec<_> = evts
.iter()
.filter(|e| matches!(e, Event::GroupMemberAdded { .. }))
.collect();
assert_eq!(
add_events.len(),
0,
"Duplicate Welcome should not produce a GroupMemberAdded event"
);
}
#[test]
fn test_relay_group_message_no_raw_on_decrypt_failure() {
let (mut _alice, mut bob, group_id) = setup_alice_bob_group("No Raw");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let corrupted = base64_encode(b"this-is-not-valid-mls-ciphertext");
let ts = chrono::Utc::now().to_rfc3339();
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&corrupted,
&ts,
"corrupt-msg-1",
None,
None,
);
let evts = events.lock().unwrap();
let msg_events: Vec<_> = evts
.iter()
.filter(|e| matches!(e, Event::GroupMessageReceived { .. }))
.collect();
assert_eq!(
msg_events.len(),
0,
"Corrupted ciphertext should not produce a GroupMessageReceived event"
);
}
#[test]
fn test_invite_commit_retry_no_panic() {
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut charlie = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
charlie.initialize_mls_for_test(storage_c).unwrap();
charlie.start().unwrap();
let (mut alice, _bob, group_id) = setup_alice_bob_group("Invite Retry");
let charlie_kp = {
let mls = charlie.mls_manager_for_testing().read().unwrap();
mls.generate_key_package().unwrap()
};
alice.pending_key_packages.insert(
id("charlie"),
crate::protocol::ReceivedKeyPackage {
key_package_data: charlie_kp.key_package_data,
local_expires_at_ms: u64::MAX,
},
);
let result = alice.invite_to_group(&group_id, &id("charlie"));
assert!(
result.is_ok(),
"invite_to_group should succeed even when sends fail"
);
}
#[test]
fn test_remove_commit_retry_no_panic() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Remove Retry");
alice
.group_mesh
.members
.entry(group_id.clone())
.and_modify(|m| {
m.push(id("charlie"));
});
let _ = alice.stop();
let result = alice.remove_from_group(&group_id, &id("bob"));
assert!(
result.is_ok(),
"remove_from_group should succeed even when sends fail"
);
}
#[test]
fn test_group_creator_is_admin() {
let (mut alice, _events) = setup_started_with_events();
let group_info = alice.create_group("Test Group").unwrap();
let group_id = group_info.group_id.as_str();
let role = alice.get_member_role(group_id, &id("user123")).unwrap();
assert_eq!(role, GroupRole::Admin, "Group creator should be admin");
}
#[test]
fn test_set_member_role_happy_path() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Role Test");
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
let role = alice.get_member_role(&group_id, &id("bob")).unwrap();
assert_eq!(role, GroupRole::Admin);
}
#[test]
fn test_set_member_role_non_admin_rejected() {
let (mut _alice, mut bob, group_id) = setup_alice_bob_group("Role Test");
let result = bob.set_member_role(&group_id, &id("alice"), GroupRole::Member);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("Only admins"),
"Non-admin should be rejected"
);
}
#[test]
fn test_last_admin_cannot_demote_self() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Role Test");
let result = alice.set_member_role(&group_id, &id("alice"), GroupRole::Member);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Cannot demote the last admin"),
"Last admin should not be able to demote self"
);
}
#[test]
fn test_admin_can_demote_self_when_other_admin_exists() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Role Test");
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
alice
.set_member_role(&group_id, &id("alice"), GroupRole::Member)
.unwrap();
let role = alice.get_member_role(&group_id, &id("alice")).unwrap();
assert_eq!(role, GroupRole::Member);
}
#[test]
fn test_get_group_roles_returns_all() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Role Test");
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
let roles = alice.get_group_roles(&group_id).unwrap();
assert_eq!(roles.get(&id("alice")), Some(&GroupRole::Admin));
assert_eq!(roles.get(&id("bob")), Some(&GroupRole::Admin));
}
#[test]
fn test_set_member_role_non_member_rejected() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Role Test");
let result = alice.set_member_role(&group_id, &id("carol"), GroupRole::Admin);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("not a member"),
"Non-member should be rejected"
);
}
#[test]
fn test_invite_requires_admin() {
let (mut _alice, mut bob, group_id) = setup_alice_bob_group("Invite Test");
let result = bob.invite_to_group(&group_id, &id("carol"));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Only admins can invite"),
"Non-admin invite should be rejected"
);
}
#[test]
fn test_remove_requires_admin() {
let (mut _alice, mut bob, group_id) = setup_alice_bob_group("Remove Test");
let result = bob.remove_from_group(&group_id, &id("alice"));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Only admins can remove"),
"Non-admin remove should be rejected"
);
}
#[test]
fn test_set_member_role_emits_event() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Event Test");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
alice.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
let captured = events.lock().unwrap();
let role_event = captured
.iter()
.find(|e| matches!(e, Event::GroupRoleChanged { .. }));
assert!(role_event.is_some(), "Should emit GroupRoleChanged event");
if let Some(Event::GroupRoleChanged {
group_id: gid,
user_id,
new_role,
changed_by,
}) = role_event
{
assert_eq!(gid, &group_id);
assert_eq!(user_id, &id("bob"));
assert_eq!(new_role, "admin");
assert_eq!(changed_by, &id("alice"));
}
}
#[test]
fn test_handle_group_role_change_non_admin_rejected() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Security Test");
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let payload = GroupRoleChangePayload {
group_id: group_id.clone(),
target_user_id: id("bob"),
new_role: GroupRole::Admin,
changed_by: id("bob"),
};
bob.handle_group_role_change(
"msg-456",
&id("bob"),
&serde_json::to_string(&payload).unwrap(),
);
let captured = events.lock().unwrap();
let role_event = captured
.iter()
.find(|e| matches!(e, Event::GroupRoleChanged { .. }));
assert!(
role_event.is_none(),
"Role change from non-admin should be rejected"
);
}
#[test]
fn test_handle_group_role_change_uses_transport_sender_not_payload() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Spoof Test");
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let payload = GroupRoleChangePayload {
group_id: group_id.clone(),
target_user_id: id("bob"),
new_role: GroupRole::Admin,
changed_by: "evil_spoofed_user".to_string(),
};
bob.handle_group_role_change(
"msg-123",
&id("alice"),
&serde_json::to_string(&payload).unwrap(),
);
let captured = events.lock().unwrap();
let role_event = captured
.iter()
.find(|e| matches!(e, Event::GroupRoleChanged { .. }));
assert!(role_event.is_some(), "Valid admin should be accepted");
if let Some(Event::GroupRoleChanged { changed_by, .. }) = role_event {
assert_eq!(
changed_by,
&id("alice"),
"changed_by must be transport-authenticated sender, not payload"
);
}
}
#[test]
fn test_group_role_enum_serialization_roundtrip() {
let admin_json = serde_json::to_string(&GroupRole::Admin).unwrap();
assert_eq!(admin_json, "\"admin\"");
let member_json = serde_json::to_string(&GroupRole::Member).unwrap();
assert_eq!(member_json, "\"member\"");
let parsed: GroupRole = serde_json::from_str("\"admin\"").unwrap();
assert_eq!(parsed, GroupRole::Admin);
let parsed: GroupRole = serde_json::from_str("\"member\"").unwrap();
assert_eq!(parsed, GroupRole::Member);
}
#[test]
fn test_group_role_fromstr() {
assert_eq!("admin".parse::<GroupRole>().unwrap(), GroupRole::Admin);
assert_eq!("member".parse::<GroupRole>().unwrap(), GroupRole::Member);
assert_eq!("Admin".parse::<GroupRole>().unwrap(), GroupRole::Admin);
assert_eq!("ADMIN".parse::<GroupRole>().unwrap(), GroupRole::Admin);
assert_eq!("Member".parse::<GroupRole>().unwrap(), GroupRole::Member);
assert_eq!("MEMBER".parse::<GroupRole>().unwrap(), GroupRole::Member);
assert!("moderator".parse::<GroupRole>().is_err());
assert!("".parse::<GroupRole>().is_err());
}
#[test]
fn test_group_role_display() {
assert_eq!(GroupRole::Admin.to_string(), "admin");
assert_eq!(GroupRole::Member.to_string(), "member");
}
#[test]
fn test_welcome_payload_carries_roles() {
let mut roles = HashMap::new();
roles.insert("alice".to_string(), GroupRole::Admin);
roles.insert("bob".to_string(), GroupRole::Member);
let payload = GroupMlsWelcomePayload {
member_rich: HashMap::new(),
member_data: HashMap::new(),
created_by: None,
group_id: "group:test".to_string(),
group_name: Some("Test".to_string()),
welcome_data: "d2VsY29tZQ==".to_string(),
member_list: vec!["alice".to_string(), "bob".to_string()],
member_roles: roles,
};
let json = serde_json::to_string(&payload).unwrap();
let parsed: GroupMlsWelcomePayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.member_roles.get("alice"), Some(&GroupRole::Admin));
assert_eq!(parsed.member_roles.get("bob"), Some(&GroupRole::Member));
}
#[test]
fn test_commit_payload_carries_role() {
let payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: "group:test".to_string(),
commit_type: GroupCommitType::Add,
ciphertext: "abc".to_string(),
epoch: 1,
affected_member: Some("bob".to_string()),
role: Some(GroupRole::Member),
};
let json = serde_json::to_string(&payload).unwrap();
let parsed: GroupMlsCommitPayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.role, Some(GroupRole::Member));
let payload_no_role = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: "group:test".to_string(),
commit_type: GroupCommitType::Remove,
ciphertext: "abc".to_string(),
epoch: 2,
affected_member: None,
role: None,
};
let json = serde_json::to_string(&payload_no_role).unwrap();
assert!(!json.contains("role"));
}
#[test]
fn test_role_change_payload_serialization() {
let payload = GroupRoleChangePayload {
group_id: "group:test".to_string(),
target_user_id: "bob".to_string(),
new_role: GroupRole::Admin,
changed_by: "alice".to_string(),
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"new_role\":\"admin\""));
let parsed: GroupRoleChangePayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.new_role, GroupRole::Admin);
}
#[test]
fn test_last_admin_cannot_leave_group_with_other_members() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Leave Test");
let result = alice.leave_group(&group_id);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("last admin"),
"Last admin should be blocked from leaving while other members remain"
);
}
#[test]
fn test_admin_can_leave_when_another_admin_exists() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Leave Test");
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
let result = alice.leave_group(&group_id);
assert!(
result.is_ok(),
"Admin should be able to leave when another admin exists"
);
}
#[test]
fn test_sole_member_admin_can_leave() {
let (mut alice, _events) = setup_started_with_events();
let group_info = alice.create_group("Solo Group").unwrap();
let group_id = group_info.group_id.as_str();
let result = alice.leave_group(group_id);
assert!(result.is_ok(), "Sole member should be able to leave");
}
#[test]
fn test_cannot_remove_last_admin_with_other_members() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Remove Test");
let result = alice.remove_from_group(&group_id, &id("bob"));
assert!(
result.is_ok(),
"Admin should be able to remove a non-admin member"
);
}
#[test]
fn test_fallback_admin_uses_created_by() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let group_info = protocol.create_group("Fallback Group").unwrap();
let group_id = group_info.group_id.as_str();
{
let mls = protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(group_id).unwrap();
mls.remove_member_role(&gid, &id("charlie")).unwrap();
}
protocol.group_mesh.members.insert(
group_id.to_string(),
vec![id("charlie"), id("alice"), id("bob")],
);
let result = protocol.set_member_role(group_id, &id("bob"), GroupRole::Admin);
assert!(
result.is_ok(),
"Creator should be fallback admin via created_by"
);
}
#[test]
fn test_fallback_admin_denies_non_creator() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group("Deny Test").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let (welcome, _commit) = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap()
};
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.join_group(&welcome).unwrap();
}
bob.group_mesh
.members
.insert(group_id.clone(), vec![id("alice"), id("bob")]);
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_member_role(&gid, &id("bob")).unwrap();
bob_mls.remove_member_role(&gid, &id("alice")).unwrap();
}
let result = bob.set_member_role(&group_id, &id("alice"), GroupRole::Member);
assert!(result.is_err(), "Non-creator should not be fallback admin");
assert!(
result.unwrap_err().to_string().contains("Only admins"),
"Should get admin-required error"
);
}
#[test]
fn test_no_metadata_denies_admin() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
protocol
.group_mesh
.members
.insert("group:phantom".to_string(), vec![id("alice"), id("bob")]);
let result = protocol.set_member_role("group:phantom", &id("bob"), GroupRole::Admin);
assert!(
matches!(result, Err(crate::Error::GroupNotFound(_))),
"Phantom group should be GroupNotFound, got {:?}",
result
);
}
#[test]
fn test_admin_gated_ops_on_missing_group_return_group_not_found() {
let (mut alice, _events) = setup_started_with_events();
let result = alice.invite_to_group("group:missing", &id("bob"));
assert!(
matches!(result, Err(crate::Error::GroupNotFound(_))),
"invite_to_group: expected GroupNotFound, got {:?}",
result
);
let result = alice.remove_from_group("group:missing", &id("bob"));
assert!(
matches!(result, Err(crate::Error::GroupNotFound(_))),
"remove_from_group: expected GroupNotFound, got {:?}",
result
);
let result = alice.set_member_role("group:missing", &id("bob"), GroupRole::Admin);
assert!(
matches!(result, Err(crate::Error::GroupNotFound(_))),
"set_member_role: expected GroupNotFound, got {:?}",
result
);
let result = alice.rename_group("group:missing", "New Name");
assert!(
matches!(result, Err(crate::Error::GroupNotFound(_))),
"rename_group: expected GroupNotFound, got {:?}",
result
);
}
#[test]
fn test_role_getters_on_metadata_less_group_return_defaults() {
use crate::mls::MlsStorage;
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage.clone()).unwrap();
let info = protocol.create_group("Legacy Group").unwrap();
let group_id = info.group_id.as_str().to_string();
storage.delete("group_metadata", &group_id).unwrap();
let role = protocol.get_member_role(&group_id, &id("user123")).unwrap();
assert_eq!(role, GroupRole::Member);
let roles = protocol.get_group_roles(&group_id).unwrap();
assert!(
roles.is_empty(),
"expected no explicit roles, got {:?}",
roles
);
let missing = protocol.get_member_role("group:missing", &id("user123"));
assert!(
matches!(missing, Err(crate::Error::GroupNotFound(_))),
"get_member_role: expected GroupNotFound, got {:?}",
missing
);
let missing = protocol.get_group_roles("group:missing");
assert!(
matches!(missing, Err(crate::Error::GroupNotFound(_))),
"get_group_roles: expected GroupNotFound, got {:?}",
missing
);
}
#[test]
fn test_legacy_roles_in_custom_map_are_migrated() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
protocol.start().unwrap();
let group_info = protocol.create_group("Migration Test").unwrap();
let group_id = group_info.group_id.as_str();
{
let mls = protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(group_id).unwrap();
mls.set_group_custom_metadata(&gid, &format!("role:{}", id("alice")), "admin")
.unwrap();
mls.set_group_custom_metadata(&gid, &format!("role:{}", id("bob")), "member")
.unwrap();
mls.remove_member_role(&gid, &id("alice")).unwrap();
}
protocol
.group_mesh
.members
.insert(group_id.to_string(), vec![id("alice"), id("bob")]);
let role = protocol.get_member_role(group_id, &id("alice")).unwrap();
assert_eq!(
role,
GroupRole::Admin,
"Legacy role should be migrated to admin"
);
let role = protocol.get_member_role(group_id, &id("bob")).unwrap();
assert_eq!(
role,
GroupRole::Member,
"Legacy role should be migrated to member"
);
}
#[test]
fn test_fallback_admin_not_used_when_roles_exist() {
let (alice, _bob, group_id) = setup_alice_bob_group("Fallback Override");
let role = alice.get_member_role(&group_id, &id("alice")).unwrap();
assert_eq!(
role,
GroupRole::Admin,
"Stored admin role should take precedence over fallback"
);
let role = alice.get_member_role(&group_id, &id("bob")).unwrap();
assert_eq!(
role,
GroupRole::Member,
"Stored member role should take precedence"
);
}
#[test]
fn test_handle_role_change_dedup() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Dedup Test");
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let payload = super::group_mesh::GroupRoleChangePayload {
group_id: group_id.clone(),
target_user_id: id("bob"),
new_role: GroupRole::Admin,
changed_by: id("alice"),
};
let data = serde_json::to_string(&payload).unwrap();
bob.handle_group_role_change("dedup-msg-1", &id("alice"), &data);
bob.handle_group_role_change("dedup-msg-1", &id("alice"), &data);
let captured = events.lock().unwrap();
let role_events: Vec<_> = captured
.iter()
.filter(|e| matches!(e, Event::GroupRoleChanged { .. }))
.collect();
assert_eq!(
role_events.len(),
1,
"Duplicate message_id should be deduped — expected 1 event, got {}",
role_events.len()
);
}
#[test]
fn test_demote_other_admin_succeeds_when_caller_remains_admin() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Demote Other Test");
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Member)
.unwrap();
assert_eq!(
alice.get_member_role(&group_id, &id("bob")).unwrap(),
GroupRole::Member
);
assert_eq!(
alice.get_member_role(&group_id, &id("alice")).unwrap(),
GroupRole::Admin,
"Alice should remain admin"
);
}
#[test]
fn test_last_admin_demotion_blocked_when_targeting_other() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Last Admin Other");
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
alice
.set_member_role(&group_id, &id("alice"), GroupRole::Member)
.unwrap();
{
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Member)
.unwrap();
let result = alice.set_member_role(&group_id, &id("alice"), GroupRole::Member);
assert!(
result.is_err(),
"Should not be able to demote the last admin"
);
assert!(result
.unwrap_err()
.to_string()
.contains("Cannot demote the last admin"));
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
alice
.set_member_role(&group_id, &id("alice"), GroupRole::Member)
.unwrap();
{
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
alice
.set_member_role(&group_id, &id("alice"), GroupRole::Member)
.unwrap();
let result = alice.set_member_role(&group_id, &id("bob"), GroupRole::Member);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("Only admins"),
"Non-admin should be rejected from demoting the last admin"
);
}
#[test]
fn test_auto_promote_after_last_admin_removed() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Auto Promote Test");
alice
.set_member_role(&group_id, &id("bob"), GroupRole::Admin)
.unwrap();
alice
.set_member_role(&group_id, &id("alice"), GroupRole::Member)
.unwrap();
{
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
let result = alice.remove_from_group(&group_id, &id("bob"));
if result.is_ok() {
let role = alice.get_member_role(&group_id, &id("alice")).unwrap();
assert_eq!(
role,
GroupRole::Admin,
"Remaining member should be admin after last admin was removed"
);
}
}
#[test]
fn test_serde_unknown_role_falls_back_to_member() {
let json = r#""moderator""#;
let role: GroupRole = serde_json::from_str(json).unwrap();
assert_eq!(
role,
GroupRole::Member,
"Unknown role variants should fall back to Member"
);
let json = r#""owner""#;
let role: GroupRole = serde_json::from_str(json).unwrap();
assert_eq!(role, GroupRole::Member);
let json = r#""admin""#;
let role: GroupRole = serde_json::from_str(json).unwrap();
assert_eq!(role, GroupRole::Admin);
}
#[test]
fn test_remove_group_custom_metadata() {
let (mut alice, _events) = setup_started_with_events();
let group_info = alice.create_group("Custom Meta Test").unwrap();
let group_id = group_info.group_id.as_str();
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(group_id).unwrap();
mls.set_group_custom_metadata(&gid, "test_key", "test_value")
.unwrap();
let meta = mls.get_group_metadata(&gid).unwrap().unwrap();
assert_eq!(meta.custom.get("test_key"), Some(&"test_value".to_string()));
mls.remove_group_custom_metadata(&gid, "test_key").unwrap();
let meta = mls.get_group_metadata(&gid).unwrap().unwrap();
assert!(
meta.custom.get("test_key").is_none(),
"Custom metadata should be removed"
);
}
#[test]
fn test_welcome_payload_roles_stored_on_join() {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group("Welcome Roles Test").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let (welcome, _commit) = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap()
};
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.join_group(&welcome).unwrap();
}
let mut roles = HashMap::new();
roles.insert(id("alice"), GroupRole::Admin);
roles.insert(id("bob"), GroupRole::Member);
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
for (user_id, role) in &roles {
bob_mls.set_member_role(&gid, user_id, *role).unwrap();
}
}
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let metadata = bob_mls.get_group_metadata(&gid).unwrap().unwrap();
assert_eq!(
metadata.get_role(&id("alice")),
GroupRole::Admin,
"Alice should be admin in Bob's local metadata after welcome"
);
assert_eq!(
metadata.get_role(&id("bob")),
GroupRole::Member,
"Bob should be member in Bob's local metadata after welcome"
);
}
#[test]
fn test_self_removal_commit_from_admin_emits_event_and_cleans_up() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Remove Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123"), id("admin_alice")]);
{
let mls = protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.set_member_role(&gid, &id("admin_alice"), GroupRole::Admin)
.unwrap();
}
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Remove,
ciphertext: base64_encode(b"undecryptable-commit-ciphertext"),
epoch: 99,
affected_member: Some(id("user123")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("admin_alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let removal = events.iter().find(
|e| matches!(e, Event::GroupMemberRemoved { user_id, .. } if user_id == &id("user123")),
);
assert!(
removal.is_some(),
"Should emit GroupMemberRemoved when admin sends remove-commit targeting us"
);
assert!(
!protocol.group_mesh.members.contains_key(&group_id),
"Group member cache should be removed after self-removal via commit"
);
}
#[test]
fn test_self_removal_commit_from_non_admin_is_rejected() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Security Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123"), "eve".to_string()]);
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Remove,
ciphertext: base64_encode(b"garbage-ciphertext"),
epoch: 99,
affected_member: Some(id("user123")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
let message = make_message(&id("eve"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let removal = events
.iter()
.find(|e| matches!(e, Event::GroupMemberRemoved { .. }));
assert!(
removal.is_none(),
"Should NOT emit GroupMemberRemoved for forged commit from non-admin"
);
assert!(
protocol.group_mesh.members.contains_key(&group_id),
"Group should NOT be cleaned up when commit is from non-admin"
);
}
#[test]
fn test_plaintext_removal_notification_from_admin_cleans_up() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Notify Test");
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = bob_events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
bob.group_mesh.relay_register_pending.insert(
group_id.clone(),
crate::group_mesh::RelayRegisterPending {
armed_at: chrono::Utc::now(),
attempts: 1,
},
);
let payload = crate::protocol::GroupMemberRemovedPayload {
group_id: group_id.clone(),
user_id: id("bob"),
removed_by: id("alice"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MEMBER_REMOVED,
serde_json::to_string(&payload).unwrap()
);
let message = make_message(&id("alice"), &id("bob"), &content);
let result = bob.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = bob_events.lock().unwrap();
let removal = events
.iter()
.find(|e| matches!(e, Event::GroupMemberRemoved { user_id, .. } if user_id == &id("bob")));
assert!(
removal.is_some(),
"Bob should emit GroupMemberRemoved from plaintext notification"
);
assert!(
!bob.group_mesh.members.contains_key(&group_id),
"Bob's group should be removed after plaintext removal notification"
);
assert!(
!bob.group_mesh
.relay_register_pending
.contains_key(&group_id),
"self-removal must clear the outstanding registration correlation"
);
assert!(
events.iter().any(|e| matches!(
e,
Event::GroupRelaySyncChanged {
group_id: g,
synced: false,
reason,
} if g == &group_id && reason == "removed"
)),
"revoking tracked relay state on self-removal must surface as a sync change"
);
}
#[test]
fn test_plaintext_removal_notification_from_non_admin_member_rejected() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Security Test 2").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123"), id("mallory")]);
let payload = crate::protocol::GroupMemberRemovedPayload {
group_id: group_id.clone(),
user_id: id("user123"),
removed_by: id("mallory"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MEMBER_REMOVED,
serde_json::to_string(&payload).unwrap()
);
let message = make_message(&id("mallory"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let removal = events
.iter()
.find(|e| matches!(e, Event::GroupMemberRemoved { .. }));
assert!(
removal.is_none(),
"Should NOT process removal notification from non-admin member"
);
assert!(
protocol.group_mesh.members.contains_key(&group_id),
"Group should NOT be removed when notification comes from non-admin"
);
}
#[test]
fn test_plaintext_removal_notification_from_nonmember_naming_admin_rejected() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Relay Evict Forgery Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let payload = crate::protocol::GroupMemberRemovedPayload {
group_id: group_id.clone(),
user_id: id("user123"),
removed_by: id("user123"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MEMBER_REMOVED,
serde_json::to_string(&payload).unwrap()
);
let message = make_message(&id("relay-server"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupMemberRemoved { .. })),
"Non-member naming an admin in removed_by must not force eviction"
);
drop(events);
assert!(
protocol.group_mesh.members.contains_key(&group_id),
"Group state must remain intact after a forged removal from a non-member"
);
}
#[test]
fn test_plaintext_removal_notification_from_relay_unverifiable_rejected() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Relay Security Test").unwrap();
let group_id = info.group_id.as_str().to_string();
let payload = crate::protocol::GroupMemberRemovedPayload {
group_id: group_id.clone(),
user_id: id("user123"),
removed_by: "fake-admin".to_string(),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MEMBER_REMOVED,
serde_json::to_string(&payload).unwrap()
);
let message = make_message(&id("attacker"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = events.lock().unwrap();
let removal = events
.iter()
.find(|e| matches!(e, Event::GroupMemberRemoved { .. }));
assert!(
removal.is_none(),
"Should NOT process removal from non-member sender with unverifiable removed_by"
);
}
#[test]
fn test_other_member_removal_from_non_admin_does_not_poison_send_cache() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Cache Poison Test").unwrap();
let group_id = info.group_id.as_str().to_string();
{
let mls = protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.set_member_role(&gid, &id("admin_alice"), GroupRole::Admin)
.unwrap();
}
protocol.group_mesh.members.insert(
group_id.clone(),
vec![id("user123"), id("admin_alice"), id("bob")],
);
let payload = crate::protocol::GroupMemberRemovedPayload {
group_id: group_id.clone(),
user_id: id("bob"),
removed_by: id("admin_alice"),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MEMBER_REMOVED,
serde_json::to_string(&payload).unwrap()
);
let message = make_message(&id("eve"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
{
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupMemberRemoved { .. })),
"Forged other-member removal from a non-admin must not emit an event"
);
let members = protocol.group_mesh.members.get(&group_id).unwrap();
assert!(
members.contains(&id("bob")),
"Non-admin removal must not drop a real member from the fan-out cache"
);
}
let message = make_message(&id("admin_alice"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let members = protocol.group_mesh.members.get(&group_id).unwrap();
assert!(
!members.contains(&id("bob")),
"An admin-authorized removal should drop the member from the cache"
);
}
#[test]
fn test_group_member_added_from_mesh_is_dropped_not_cache_poisoned() {
let (mut protocol, events) = setup_started_with_events();
let info = protocol.create_group("Add Poison Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123")]);
let payload = crate::protocol::GroupMemberAddedPayload {
group_id: group_id.clone(),
user_id: "eve".to_string(),
added_by: id("user123"),
group_name: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MEMBER_ADDED,
serde_json::to_string(&payload).unwrap()
);
let message = make_message(&id("eve"), &id("user123"), &content);
let result = protocol.process_internal_message_via(&message, Some(TransportType::BLE));
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
{
let members = protocol.group_mesh.members.get(&group_id).unwrap();
assert!(
!members.contains(&"eve".to_string()),
"Mesh-forged __GROUP_MEMBER_ADDED__ must not splice a phantom into the fan-out cache"
);
let events = events.lock().unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::GroupMemberAdded { .. })),
"Mesh-forged __GROUP_MEMBER_ADDED__ must not emit a roster event"
);
}
let message = make_message(&id("user123"), &id("user123"), &content);
let result = protocol.process_internal_message_via(&message, Some(TransportType::Internet));
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let members = protocol.group_mesh.members.get(&group_id).unwrap();
assert!(
members.contains(&"eve".to_string()),
"Internet-arrival relay reconciliation should update the fan-out cache"
);
}
#[test]
fn test_group_member_added_over_internet_accepted_regardless_of_sender_documents_residual() {
let (mut protocol, _events) = setup_started_with_events();
let info = protocol.create_group("Residual Doc Test").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol
.group_mesh
.members
.insert(group_id.clone(), vec![id("user123")]);
let payload = crate::protocol::GroupMemberAddedPayload {
group_id: group_id.clone(),
user_id: "eve".to_string(),
added_by: id("user123"),
group_name: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MEMBER_ADDED,
serde_json::to_string(&payload).unwrap()
);
let message = make_message(&id("mallory"), &id("user123"), &content);
let result = protocol.process_internal_message_via(&message, Some(TransportType::Internet));
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let members = protocol.group_mesh.members.get(&group_id).unwrap();
assert!(
members.contains(&"eve".to_string()),
"Internet-arrival GROUP_MEMBER_ADDED is accepted regardless of sender (documented residual)"
);
}
#[test]
fn test_key_package_sent_to_cleared_after_invite_consumption() {
let (mut alice, _bob, _group_id) = setup_alice_bob_group("KP Test");
alice.key_package_sent_to.insert(id("bob"));
alice.key_package_sent_to.remove(&id("bob"));
assert!(
!alice.key_package_sent_to.contains(&id("bob")),
"key_package_sent_to should be cleared for invitee after invite"
);
}
#[test]
fn test_welcome_handler_clears_key_package_sent_to() {
let (_alice, mut bob, _group_id) = setup_alice_bob_group("Welcome KP Test");
bob.key_package_sent_to.insert(id("alice"));
bob.key_package_sent_to.remove(&id("alice"));
assert!(
!bob.key_package_sent_to.contains(&id("alice")),
"key_package_sent_to should be cleared for inviter after Welcome"
);
}
#[test]
fn test_rename_group_success() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Original Name");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
alice.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
alice.rename_group(&group_id, "New Name").unwrap();
let info = alice.get_group_info(&group_id).unwrap().unwrap();
assert_eq!(info.name.as_deref(), Some("New Name"));
let captured = events.lock().unwrap();
let rename_event = captured
.iter()
.find(|e| matches!(e, Event::GroupRenamed { .. }));
assert!(rename_event.is_some(), "Should emit GroupRenamed event");
if let Some(Event::GroupRenamed {
group_id: gid,
new_name,
old_name,
renamed_by,
}) = rename_event
{
assert_eq!(gid, &group_id);
assert_eq!(new_name, "New Name");
assert_eq!(old_name.as_deref(), Some("Original Name"));
assert_eq!(renamed_by, &id("alice"));
}
}
#[test]
fn test_rename_group_non_admin_rejected() {
let (mut _alice, mut bob, group_id) = setup_alice_bob_group("Original Name");
let result = bob.rename_group(&group_id, "Bob's Name");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Only admins can rename"),
"Non-admin rename should be rejected"
);
}
#[test]
fn test_handle_group_rename_from_admin() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Original Name");
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
bob_mls.set_group_name(&gid, "Original Name").unwrap();
}
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let payload = GroupRenamePayload {
group_id: group_id.clone(),
new_name: "Renamed By Alice".to_string(),
renamed_by: id("alice"),
};
bob.handle_group_rename(
"msg-rename-1",
&id("alice"),
&serde_json::to_string(&payload).unwrap(),
);
let info = bob.get_group_info(&group_id).unwrap().unwrap();
assert_eq!(info.name.as_deref(), Some("Renamed By Alice"));
let captured = events.lock().unwrap();
let rename_event = captured
.iter()
.find(|e| matches!(e, Event::GroupRenamed { .. }));
assert!(rename_event.is_some(), "Should emit GroupRenamed event");
if let Some(Event::GroupRenamed { renamed_by, .. }) = rename_event {
assert_eq!(renamed_by, &id("alice"), "Should use transport sender");
}
}
#[test]
fn test_handle_group_rename_from_non_admin_rejected() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Original Name");
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let payload = GroupRenamePayload {
group_id: group_id.clone(),
new_name: "Hacked Name".to_string(),
renamed_by: id("bob"),
};
bob.handle_group_rename(
"msg-rename-2",
&id("bob"),
&serde_json::to_string(&payload).unwrap(),
);
let captured = events.lock().unwrap();
let rename_event = captured
.iter()
.find(|e| matches!(e, Event::GroupRenamed { .. }));
assert!(
rename_event.is_none(),
"Rename from non-admin should be rejected"
);
}
#[test]
fn test_handle_group_rename_dedup() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Original Name");
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let payload = GroupRenamePayload {
group_id: group_id.clone(),
new_name: "New Name".to_string(),
renamed_by: id("alice"),
};
let json = serde_json::to_string(&payload).unwrap();
bob.handle_group_rename("msg-rename-dup", &id("alice"), &json);
bob.handle_group_rename("msg-rename-dup", &id("alice"), &json);
let captured = events.lock().unwrap();
let rename_count = captured
.iter()
.filter(|e| matches!(e, Event::GroupRenamed { .. }))
.count();
assert_eq!(rename_count, 1, "Duplicate rename should be deduplicated");
}
#[test]
fn test_handle_group_rename_uses_transport_sender() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Original Name");
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
}
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let payload = GroupRenamePayload {
group_id: group_id.clone(),
new_name: "Spoofed Name".to_string(),
renamed_by: id("mallory"),
};
bob.handle_group_rename(
"msg-rename-spoof",
&id("alice"),
&serde_json::to_string(&payload).unwrap(),
);
let captured = events.lock().unwrap();
if let Some(Event::GroupRenamed { renamed_by, .. }) = captured
.iter()
.find(|e| matches!(e, Event::GroupRenamed { .. }))
{
assert_eq!(
renamed_by,
&id("alice"),
"renamed_by should be transport sender, not payload field"
);
} else {
panic!("Expected GroupRenamed event");
}
}
#[test]
fn test_rename_group_payload_serialization() {
let payload = GroupRenamePayload {
group_id: "grp-123".to_string(),
new_name: "Test Name".to_string(),
renamed_by: "alice".to_string(),
};
let json = serde_json::to_string(&payload).unwrap();
let parsed: GroupRenamePayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.group_id, "grp-123");
assert_eq!(parsed.new_name, "Test Name");
assert_eq!(parsed.renamed_by, "alice".to_string());
}
#[test]
fn test_rename_group_empty_name_rejected() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Original Name");
let result = alice.rename_group(&group_id, "");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
let result = alice.rename_group(&group_id, " ");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn test_create_group_empty_name_rejected() {
let storage = Arc::new(crate::mls::InMemoryStorage::default());
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.initialize_mls_for_test(storage).unwrap();
let result = protocol.create_group("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
let result = protocol.create_group(" ");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
fn make_group_mls_msg_json(sender: &OfflineProtocol, group_id: &str, text: &str) -> String {
let encrypted = {
let mls = sender.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(group_id).unwrap();
mls.encrypt_for_group(&gid, text.as_bytes()).unwrap()
};
serde_json::json!({
"group_id": group_id,
"ciphertext": base64_encode(&encrypted.ciphertext),
"epoch": encrypted.epoch,
})
.to_string()
}
fn setup_race_alice_bob() -> (
OfflineProtocol,
OfflineProtocol,
Arc<Mutex<Vec<Event>>>,
String,
String,
) {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let group_info = alice.create_group("Race Group").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let welcome = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (welcome, _commit) = alice_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap();
welcome
};
alice.refresh_group_members(&group_id).unwrap();
let welcome_json = serde_json::json!({
"group_id": group_id,
"group_name": "Race Group",
"welcome_data": base64_encode(&welcome.welcome_data),
"member_list": [&id("alice"), &id("bob")],
})
.to_string();
(alice, bob, events, group_id, welcome_json)
}
fn group_messages_received(events: &Arc<Mutex<Vec<Event>>>) -> Vec<(String, String, String)> {
events
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
Event::GroupMessageReceived {
content,
message_id,
timestamp,
..
} => Some((content.clone(), message_id.clone(), timestamp.clone())),
_ => None,
})
.collect()
}
#[test]
fn test_group_message_before_welcome_buffered_then_delivered_on_join() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let msg_json = make_group_mls_msg_json(&alice, &group_id, "hello race");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
let result = bob.handle_group_mls_msg(&wire, &id("alice"), &msg_json);
assert!(matches!(result, InternalMessageResult::Deferred));
assert!(group_messages_received(&events).is_empty());
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(1),
"Message arriving before the Welcome should be buffered"
);
bob.handle_group_mls_welcome("welcome-race-1", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(
received.len(),
1,
"Buffered message should be delivered after the Welcome"
);
assert_eq!(received[0].0, "hello race");
assert_eq!(received[0].1, wire.id.as_str());
assert!(
!bob.group_mesh
.pending_group_messages
.contains_key(&group_id),
"Buffer should be empty after drain"
);
}
#[test]
fn test_group_message_before_welcome_redelivery_not_duplicated() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let msg_json = make_group_mls_msg_json(&alice, &group_id, "hello race");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
bob.handle_group_mls_msg(&wire, &id("alice"), &msg_json);
bob.handle_group_mls_msg(&wire, &id("alice"), &msg_json);
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(1),
"Redelivery must not create a second buffered copy"
);
bob.handle_group_mls_welcome("welcome-race-2", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(received.len(), 1, "Exactly one delivery after the Welcome");
assert_eq!(received[0].0, "hello race");
}
fn attach_ble_mock(p: &mut OfflineProtocol) -> offline_protocol_transport::mock::MockTransport {
use offline_protocol_transport::mock::MockTransport;
let ble = MockTransport::new(TransportType::BLE);
ble.start().unwrap();
let handle = ble.clone();
p.transport_manager_mut()
.add_transport(TransportType::BLE, Box::new(ble));
handle
}
fn ack_count(transport: &offline_protocol_transport::mock::MockTransport, msg_id: &str) -> usize {
transport
.sent_messages()
.iter()
.filter(|m| {
m.metadata
.get(crate::constants::ACK_FOR_KEY)
.map(String::as_str)
== Some(msg_id)
})
.count()
}
#[test]
fn test_deferred_group_msg_defers_ack_then_recovers_without_loss_or_dup() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let ble = attach_ble_mock(&mut bob);
let msg_json = make_group_mls_msg_json(&alice, &group_id, "hello race");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
let msg_id = wire.id.as_str().to_string();
let result =
bob.handle_group_mls_msg_via(&wire, &id("alice"), &msg_json, Some(TransportType::BLE));
assert!(matches!(result, InternalMessageResult::Deferred));
assert!(group_messages_received(&events).is_empty());
assert_eq!(
ack_count(&ble, &msg_id),
0,
"a buffered-but-undecrypted message must not be ACKed"
);
bob.handle_group_mls_welcome("welcome-defer-1", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(received.len(), 1, "exactly one delivery — no loss, no dup");
assert_eq!(received[0].0, "hello race");
assert_eq!(
ack_count(&ble, &msg_id),
1,
"the delivered message is ACKed exactly once, on drain"
);
assert!(!bob
.group_mesh
.pending_group_messages
.contains_key(&group_id));
}
#[test]
fn test_deferred_group_msg_is_acked_on_drain_without_a_resend() {
let (alice, mut bob, _events, group_id, welcome_json) = setup_race_alice_bob();
let ble = attach_ble_mock(&mut bob);
let msg_json = make_group_mls_msg_json(&alice, &group_id, "one and only");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
let msg_id = wire.id.as_str().to_string();
bob.handle_group_mls_msg_via(&wire, &id("alice"), &msg_json, Some(TransportType::BLE));
bob.handle_group_mls_welcome("welcome-defer-2", &id("alice"), &welcome_json);
let acks: Vec<_> = ble
.sent_messages()
.into_iter()
.filter(|m| {
m.metadata
.get(crate::constants::ACK_FOR_KEY)
.map(String::as_str)
== Some(msg_id.as_str())
})
.collect();
assert_eq!(acks.len(), 1, "delivered on drain, ACKed without a resend");
assert_eq!(acks[0].recipient.as_str(), id("alice"));
}
#[test]
fn test_group_dup_while_pending_defers_not_reacks() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let ble = attach_ble_mock(&mut bob);
let msg_json = make_group_mls_msg_json(&alice, &group_id, "hello race");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
let msg_id = wire.id.as_str().to_string();
let r1 = bob.handle_group_mls_msg_via(&wire, &id("alice"), &msg_json, Some(TransportType::BLE));
assert!(matches!(r1, InternalMessageResult::Deferred));
let r2 = bob.handle_group_mls_msg_via(&wire, &id("alice"), &msg_json, Some(TransportType::BLE));
assert!(matches!(r2, InternalMessageResult::Deferred));
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(1),
"a duplicate of a pending message must not stack a second copy"
);
assert_eq!(
ack_count(&ble, &msg_id),
0,
"a duplicate of a pending message is never ACKed"
);
bob.handle_group_mls_welcome("welcome-defer-3", &id("alice"), &welcome_json);
assert_eq!(group_messages_received(&events).len(), 1);
assert_eq!(ack_count(&ble, &msg_id), 1);
}
#[test]
fn test_group_dup_after_delivery_reacks_and_not_redelivered() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let ble = attach_ble_mock(&mut bob);
let msg_json = make_group_mls_msg_json(&alice, &group_id, "hello race");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
let msg_id = wire.id.as_str().to_string();
bob.handle_group_mls_msg_via(&wire, &id("alice"), &msg_json, Some(TransportType::BLE));
bob.handle_group_mls_welcome("welcome-defer-4", &id("alice"), &welcome_json);
assert_eq!(group_messages_received(&events).len(), 1);
assert_eq!(ack_count(&ble, &msg_id), 1);
let r = bob.handle_group_mls_msg_via(&wire, &id("alice"), &msg_json, Some(TransportType::BLE));
assert!(matches!(r, InternalMessageResult::Consumed));
assert_eq!(
group_messages_received(&events).len(),
1,
"a delivered message must not be redelivered on resend"
);
assert_eq!(ack_count(&ble, &msg_id), 1);
}
#[test]
fn test_both_buffered_copies_of_one_logical_message_deliver_exactly_once() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let ble = attach_ble_mock(&mut bob);
let encrypted = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
mls.encrypt_for_group(&gid, b"one logical message").unwrap()
};
let ciphertext_b64 = base64_encode(&encrypted.ciphertext);
let logical = "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb";
let msg_json = serde_json::json!({
"group_id": group_id,
"ciphertext": ciphertext_b64,
"epoch": encrypted.epoch,
"message_id": logical,
})
.to_string();
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
let envelope_id = wire.id.as_str().to_string();
let r = bob.handle_group_mls_msg_via(&wire, &id("alice"), &msg_json, Some(TransportType::BLE));
assert!(matches!(r, InternalMessageResult::Deferred));
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&ciphertext_b64,
"2026-07-31T00:00:00Z",
logical,
None,
None,
);
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(2),
"both copies buffer while group state lags"
);
bob.handle_group_mls_welcome("welcome-both-copies", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(
received.len(),
1,
"exactly one delivery — a sibling buffered copy must not suppress it"
);
assert_eq!(received[0].0, "one logical message");
assert_eq!(
received[0].1, logical,
"delivered under the logical id every member shares"
);
assert_eq!(
ack_count(&ble, &envelope_id),
1,
"the mesh sender's frame is ACKed on drain"
);
assert!(
!bob.group_mesh
.pending_group_messages
.contains_key(&group_id),
"the spent second copy is dropped, not re-buffered as noise"
);
assert!(
bob.group_mesh.message_dedup.contains_key(logical),
"replay protection for a delivered id must survive the drain"
);
}
#[test]
fn test_evicted_pending_group_msg_recovers_on_resend_after_state_ready() {
let (alice, mut bob, _events, group_id, _welcome_json) = setup_race_alice_bob();
let cap = MAX_PENDING_GROUP_MESSAGES_PER_GROUP;
let first_json = make_group_mls_msg_json(&alice, &group_id, "m0");
let first_wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
let first_id = first_wire.id.as_str().to_string();
let r0 = bob.handle_group_mls_msg_via(
&first_wire,
&id("alice"),
&first_json,
Some(TransportType::BLE),
);
assert!(matches!(r0, InternalMessageResult::Deferred));
for i in 1..=cap {
let json = make_group_mls_msg_json(&alice, &group_id, &format!("m{i}"));
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
let r = bob.handle_group_mls_msg_via(&wire, &id("alice"), &json, Some(TransportType::BLE));
assert!(matches!(r, InternalMessageResult::Deferred));
}
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(cap),
);
assert!(
!bob.group_mesh.message_dedup.contains_key(&first_id),
"the evicted message's dedup entry must be released"
);
let r = bob.handle_group_mls_msg_via(
&first_wire,
&id("alice"),
&first_json,
Some(TransportType::BLE),
);
assert!(
matches!(r, InternalMessageResult::Deferred),
"a released message must re-buffer on resend, not be swallowed"
);
assert!(bob.group_mesh.message_dedup.contains_key(&first_id));
}
#[test]
fn test_group_message_at_future_epoch_buffered_then_delivered_after_commit() {
let (mut alice, mut bob, group_id) = setup_alice_bob_group("Epoch Race");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut charlie = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
charlie.initialize_mls_for_test(storage_c).unwrap();
let charlie_kp = {
let charlie_mls = charlie.mls_manager_for_testing().read().unwrap();
charlie_mls.generate_key_package().unwrap()
};
let commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (_welcome, commit) = alice_mls
.add_group_member(&gid, &id("charlie"), &charlie_kp.key_package_data)
.unwrap();
commit
};
alice.refresh_group_members(&group_id).unwrap();
let msg_json = make_group_mls_msg_json(&alice, &group_id, "after epoch bump");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
bob.handle_group_mls_msg(&wire, &id("alice"), &msg_json);
assert!(group_messages_received(&events).is_empty());
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(1),
"Future-epoch message should be buffered"
);
let commit_json = serde_json::json!({
"group_id": group_id,
"commit_type": "add",
"ciphertext": base64_encode(&commit.ciphertext),
"epoch": commit.epoch,
"affected_member": &id("charlie"),
})
.to_string();
bob.handle_group_mls_commit("commit-epoch-race", &id("alice"), &commit_json);
let received = group_messages_received(&events);
assert_eq!(
received.len(),
1,
"Buffered message should be delivered after the commit advances the epoch"
);
assert_eq!(received[0].0, "after epoch bump");
}
#[test]
fn test_relay_group_message_before_welcome_buffered_then_delivered() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let ciphertext_b64 = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
base64_encode(
&mls.encrypt_for_group(&gid, b"relay race")
.unwrap()
.ciphertext,
)
};
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&ciphertext_b64,
"2026-07-10T00:00:00Z",
"relay-race-1",
None,
None,
);
assert!(group_messages_received(&events).is_empty());
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(1),
"Relay message arriving before the Welcome should be buffered"
);
bob.handle_group_mls_welcome("welcome-race-3", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(received.len(), 1);
assert_eq!(received[0].0, "relay race");
assert_eq!(received[0].1, "relay-race-1");
assert_eq!(
received[0].2, "2026-07-10T00:00:00Z",
"Relay-provided timestamp should be preserved through the buffer"
);
}
#[test]
fn test_commit_riding_message_channel_drains_buffered_messages() {
let (mut alice, mut bob, group_id) = setup_alice_bob_group("NonApp Drain");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
bob.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut charlie = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
charlie.initialize_mls_for_test(storage_c).unwrap();
let charlie_kp = {
let charlie_mls = charlie.mls_manager_for_testing().read().unwrap();
charlie_mls.generate_key_package().unwrap()
};
let commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (_welcome, commit) = alice_mls
.add_group_member(&gid, &id("charlie"), &charlie_kp.key_package_data)
.unwrap();
commit
};
alice.refresh_group_members(&group_id).unwrap();
let msg_json = make_group_mls_msg_json(&alice, &group_id, "unblocked by riding commit");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
bob.handle_group_mls_msg(&wire, &id("alice"), &msg_json);
assert!(group_messages_received(&events).is_empty());
let commit_as_msg_json = serde_json::json!({
"group_id": group_id,
"ciphertext": base64_encode(&commit.ciphertext),
"epoch": commit.epoch,
})
.to_string();
let wire2 = make_message(&id("alice"), &id("bob"), "unused-envelope");
let result = bob.handle_group_mls_msg(&wire2, &id("alice"), &commit_as_msg_json);
assert!(matches!(result, InternalMessageResult::Consumed));
let received = group_messages_received(&events);
assert_eq!(
received.len(),
1,
"Buffered message should be delivered after the riding commit advances the epoch"
);
assert_eq!(received[0].0, "unblocked by riding commit");
assert!(
!bob.group_mesh
.pending_group_messages
.contains_key(&group_id),
"Buffer should be empty after the NonApplication-triggered drain"
);
}
#[test]
fn test_buffered_commit_riding_message_channel_unblocks_earlier_entries() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut charlie = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
charlie.initialize_mls_for_test(storage_c).unwrap();
let charlie_kp = {
let charlie_mls = charlie.mls_manager_for_testing().read().unwrap();
charlie_mls.generate_key_package().unwrap()
};
let commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (_welcome, commit) = alice_mls
.add_group_member(&gid, &id("charlie"), &charlie_kp.key_package_data)
.unwrap();
commit
};
let msg_json = make_group_mls_msg_json(&alice, &group_id, "needs two passes");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
bob.handle_group_mls_msg(&wire, &id("alice"), &msg_json);
let commit_as_msg_json = serde_json::json!({
"group_id": group_id,
"ciphertext": base64_encode(&commit.ciphertext),
"epoch": commit.epoch,
})
.to_string();
let wire2 = make_message(&id("alice"), &id("bob"), "unused-envelope");
bob.handle_group_mls_msg(&wire2, &id("alice"), &commit_as_msg_json);
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(2)
);
bob.handle_group_mls_welcome("welcome-nonapp-race", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(
received.len(),
1,
"Second drain pass should deliver the message the riding commit unblocked"
);
assert_eq!(received[0].0, "needs two passes");
assert!(
!bob.group_mesh
.pending_group_messages
.contains_key(&group_id),
"Buffer should be empty once both passes complete"
);
}
#[test]
fn test_pending_group_message_buffer_cap() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
for i in 0..(MAX_PENDING_GROUP_MESSAGES_PER_GROUP + 2) {
protocol.buffer_pending_group_message(
"cap-group",
PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: format!("m{}", i),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now(),
received_via: None,
},
);
}
let buf = protocol
.group_mesh
.pending_group_messages
.get("cap-group")
.unwrap();
assert_eq!(buf.len(), MAX_PENDING_GROUP_MESSAGES_PER_GROUP);
assert_eq!(buf[0].message_id, "m2", "Oldest entries should be dropped");
}
#[test]
fn test_pending_group_message_expired_entries_cleaned_up() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let expired = PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: "expired-1".to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now() - StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS + 10),
received_via: None,
};
let recent = PendingGroupMessage {
logical_id: None,
sender: "bob".to_string(),
message_id: "recent-1".to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now(),
received_via: None,
};
protocol
.group_mesh
.pending_group_messages
.entry("ttl-group".to_string())
.or_default()
.extend([expired, recent]);
protocol.cleanup_group_message_dedup();
let buf = protocol
.group_mesh
.pending_group_messages
.get("ttl-group")
.unwrap();
assert_eq!(
buf.len(),
1,
"Only the recent pending message should survive"
);
assert_eq!(buf[0].message_id, "recent-1");
}
#[test]
fn test_drain_pending_group_messages_drops_expired() {
let (mut protocol, events) = setup_with_events();
let expired = PendingGroupMessage {
logical_id: None,
sender: id("alice"),
message_id: "expired-drain-1".to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now() - StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS + 10),
received_via: None,
};
protocol
.group_mesh
.pending_group_messages
.entry("drain-group".to_string())
.or_default()
.push_back(expired);
protocol.drain_pending_group_messages("drain-group");
assert!(
!protocol
.group_mesh
.pending_group_messages
.contains_key("drain-group"),
"Expired entry should be dropped on drain"
);
assert!(group_messages_received(&events).is_empty());
}
#[test]
fn test_commit_and_message_both_outrun_welcome() {
let (mut alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut charlie = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
charlie.initialize_mls_for_test(storage_c).unwrap();
let charlie_kp = {
let charlie_mls = charlie.mls_manager_for_testing().read().unwrap();
charlie_mls.generate_key_package().unwrap()
};
let commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (_welcome, commit) = alice_mls
.add_group_member(&gid, &id("charlie"), &charlie_kp.key_package_data)
.unwrap();
commit
};
alice.refresh_group_members(&group_id).unwrap();
let commit_json = serde_json::json!({
"group_id": group_id,
"commit_type": "add",
"ciphertext": base64_encode(&commit.ciphertext),
"epoch": commit.epoch,
"affected_member": &id("charlie"),
})
.to_string();
bob.handle_group_mls_commit("commit-before-welcome", &id("alice"), &commit_json);
assert_eq!(
bob.group_mesh
.pending_commits
.get(&group_id)
.map(|b| b.len()),
Some(1),
"Commit arriving before the Welcome should be buffered"
);
let msg_json = make_group_mls_msg_json(&alice, &group_id, "outran everything");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
bob.handle_group_mls_msg(&wire, &id("alice"), &msg_json);
assert!(group_messages_received(&events).is_empty());
bob.handle_group_mls_welcome("welcome-after-commit", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(
received.len(),
1,
"Buffered message should be delivered once the buffered commit is applied on join"
);
assert_eq!(received[0].0, "outran everything");
assert!(
!bob.group_mesh.pending_commits.contains_key(&group_id),
"Buffered commit should be consumed by the Welcome-path drain"
);
assert!(
!bob.group_mesh
.pending_group_messages
.contains_key(&group_id),
"Buffered message should be consumed by the Welcome-path drain"
);
}
#[test]
fn test_pending_group_message_spread_flood_bounded_by_group_cap() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let total = MAX_PENDING_GROUP_MESSAGE_GROUPS + 8;
for i in 0..total {
protocol
.group_mesh
.message_dedup
.insert(format!("m{}", i), Instant::now());
protocol.buffer_pending_group_message(
&format!("group-{}", i),
PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: format!("m{}", i),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now() - StdDuration::from_millis((total - i) as u64),
received_via: None,
},
);
}
assert_eq!(
protocol.group_mesh.pending_group_messages.len(),
MAX_PENDING_GROUP_MESSAGE_GROUPS,
"Distinct buffered groups must be capped"
);
assert!(
!protocol
.group_mesh
.pending_group_messages
.contains_key("group-0"),
"Oldest spread entry should be evicted first"
);
assert!(protocol
.group_mesh
.pending_group_messages
.contains_key(&format!("group-{}", total - 1)));
assert!(!protocol.group_mesh.message_dedup.contains_key("m0"));
assert!(!protocol.group_mesh.message_dedup.contains_key("m7"));
assert!(protocol.group_mesh.message_dedup.contains_key("m8"));
assert!(protocol
.group_mesh
.message_dedup
.contains_key(&format!("m{}", total - 1)));
}
#[test]
fn test_evicted_pending_group_message_releases_dedup_entry() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
for i in 0..(MAX_PENDING_GROUP_MESSAGES_PER_GROUP + 1) {
protocol
.group_mesh
.message_dedup
.insert(format!("evict{}", i), Instant::now());
protocol.buffer_pending_group_message(
"evict-group",
PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: format!("evict{}", i),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now(),
received_via: None,
},
);
}
assert!(!protocol.group_mesh.message_dedup.contains_key("evict0"));
assert!(protocol.group_mesh.message_dedup.contains_key("evict1"));
assert_eq!(
protocol
.group_mesh
.pending_group_messages
.get("evict-group")
.map(|b| b.len()),
Some(MAX_PENDING_GROUP_MESSAGES_PER_GROUP)
);
}
#[test]
fn test_drain_expired_pending_group_message_releases_dedup_entry() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol
.group_mesh
.message_dedup
.insert("expired-dedup-1".to_string(), Instant::now());
protocol
.group_mesh
.pending_group_messages
.entry("dedup-ttl-group".to_string())
.or_default()
.push_back(PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: "expired-dedup-1".to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now()
- StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS + 10),
received_via: None,
});
protocol.drain_pending_group_messages("dedup-ttl-group");
assert!(
!protocol
.group_mesh
.pending_group_messages
.contains_key("dedup-ttl-group"),
"Expired entry should be dropped on drain"
);
assert!(
!protocol
.group_mesh
.message_dedup
.contains_key("expired-dedup-1"),
"Dropping an undelivered expired message must release its dedup ID"
);
}
#[test]
fn test_evicted_pending_commit_releases_dedup_entry() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
for i in 0..(MAX_PENDING_COMMITS_PER_GROUP + 1) {
protocol
.group_mesh
.message_dedup
.insert(format!("cevict{}", i), Instant::now());
protocol.buffer_pending_commit(
"commit-evict-group",
&format!("cevict{}", i),
"alice",
"commit-data",
);
}
assert!(!protocol.group_mesh.message_dedup.contains_key("cevict0"));
assert!(protocol.group_mesh.message_dedup.contains_key("cevict1"));
assert_eq!(
protocol
.group_mesh
.pending_commits
.get("commit-evict-group")
.map(|b| b.len()),
Some(MAX_PENDING_COMMITS_PER_GROUP)
);
}
#[test]
fn test_drain_expired_pending_commit_releases_dedup_entry() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol
.group_mesh
.message_dedup
.insert("expired-commit-1".to_string(), Instant::now());
protocol
.group_mesh
.pending_commits
.entry("commit-ttl-group".to_string())
.or_default()
.push_back(PendingCommit {
sender: "alice".to_string(),
message_id: "expired-commit-1".to_string(),
data: "{}".to_string(),
buffered_at: Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10),
retry_count: 0,
});
protocol.drain_pending_commits("commit-ttl-group");
assert!(
!protocol
.group_mesh
.pending_commits
.contains_key("commit-ttl-group"),
"Expired commit should be dropped on drain"
);
assert!(
!protocol
.group_mesh
.message_dedup
.contains_key("expired-commit-1"),
"Dropping an unprocessed expired commit must release its dedup ID"
);
}
#[test]
fn test_group_cap_eviction_evicts_single_group_wholesale() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let per_group = 4usize;
for g in 0..MAX_PENDING_GROUP_MESSAGE_GROUPS {
for j in 0..per_group {
let mid = format!("wg-{}-{}", g, j);
protocol
.group_mesh
.message_dedup
.insert(mid.clone(), Instant::now());
protocol.buffer_pending_group_message(
&format!("wg-group-{}", g),
PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: mid,
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now()
- StdDuration::from_millis(
((MAX_PENDING_GROUP_MESSAGE_GROUPS - g) * 100 + (per_group - j)) as u64,
),
received_via: None,
},
);
}
}
protocol
.group_mesh
.message_dedup
.insert("wg-fresh".to_string(), Instant::now());
protocol.buffer_pending_group_message(
"wg-group-fresh",
PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: "wg-fresh".to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now(),
received_via: None,
},
);
assert!(
!protocol
.group_mesh
.pending_group_messages
.contains_key("wg-group-0"),
"The group with the oldest front entry should be evicted wholesale"
);
assert_eq!(
protocol.group_mesh.pending_group_messages.len(),
MAX_PENDING_GROUP_MESSAGE_GROUPS
);
let total: usize = protocol
.group_mesh
.pending_group_messages
.values()
.map(|b| b.len())
.sum();
assert_eq!(
total,
MAX_PENDING_GROUP_MESSAGE_GROUPS * per_group - per_group + 1,
"Only the victim group's entries may be evicted"
);
assert_eq!(
protocol
.group_mesh
.pending_group_messages
.get("wg-group-1")
.map(|b| b.len()),
Some(per_group),
"Non-victim groups must be untouched"
);
for j in 0..per_group {
assert!(!protocol
.group_mesh
.message_dedup
.contains_key(&format!("wg-0-{}", j)));
}
assert!(protocol.group_mesh.message_dedup.contains_key("wg-1-0"));
}
#[test]
fn test_pending_commit_group_cap_eviction_evicts_single_group_wholesale() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let per_group = 3usize;
for g in 0..MAX_PENDING_COMMIT_GROUPS {
for j in 0..per_group {
let mid = format!("cg-{}-{}", g, j);
protocol
.group_mesh
.message_dedup
.insert(mid.clone(), Instant::now());
protocol
.group_mesh
.pending_commits
.entry(format!("cg-group-{}", g))
.or_default()
.push_back(PendingCommit {
sender: "alice".to_string(),
message_id: mid,
data: "commit-data".to_string(),
buffered_at: Instant::now()
- StdDuration::from_millis(
((MAX_PENDING_COMMIT_GROUPS - g) * 100 + (per_group - j)) as u64,
),
retry_count: 0,
});
}
}
protocol
.group_mesh
.message_dedup
.insert("cg-fresh".to_string(), Instant::now());
protocol.buffer_pending_commit("cg-group-fresh", "cg-fresh", "alice", "fresh-commit");
assert!(
!protocol
.group_mesh
.pending_commits
.contains_key("cg-group-0"),
"The group with the oldest front entry should be evicted wholesale"
);
assert_eq!(
protocol.group_mesh.pending_commits.len(),
MAX_PENDING_COMMIT_GROUPS
);
let total: usize = protocol
.group_mesh
.pending_commits
.values()
.map(|b| b.len())
.sum();
assert_eq!(
total,
MAX_PENDING_COMMIT_GROUPS * per_group - per_group + 1,
"Only the victim group's commits may be evicted"
);
for j in 0..per_group {
assert!(!protocol
.group_mesh
.message_dedup
.contains_key(&format!("cg-0-{}", j)));
}
assert!(protocol.group_mesh.message_dedup.contains_key("cg-1-0"));
assert!(protocol.group_mesh.message_dedup.contains_key("cg-fresh"));
}
#[test]
fn test_pending_group_message_global_byte_cap() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let big = "A".repeat(1024 * 1024);
let n = MAX_PENDING_GROUP_MESSAGE_TOTAL_BYTES / big.len() + 2;
for i in 0..n {
protocol.buffer_pending_group_message(
&format!("big-group-{}", i),
PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: format!("big{}", i),
ciphertext_b64: big.clone(),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now() - StdDuration::from_millis((n - i) as u64),
received_via: None,
},
);
}
let bytes: usize = protocol
.group_mesh
.pending_group_messages
.values()
.flat_map(|b| b.iter())
.map(|m| m.ciphertext_b64.len())
.sum();
assert!(
bytes <= MAX_PENDING_GROUP_MESSAGE_TOTAL_BYTES,
"Total buffered ciphertext bytes ({}) must stay within the global cap",
bytes
);
assert!(
!protocol
.group_mesh
.pending_group_messages
.contains_key("big-group-0"),
"Oldest oversized entry should be evicted"
);
}
#[test]
fn test_pending_commit_global_entry_cap_concentrated_flood() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let groups = MAX_PENDING_COMMITS_TOTAL / MAX_PENDING_COMMITS_PER_GROUP + 1;
assert!(groups <= MAX_PENDING_COMMIT_GROUPS);
for g in 0..groups {
for j in 0..MAX_PENDING_COMMITS_PER_GROUP {
protocol.buffer_pending_commit(
&format!("commit-group-{}", g),
&format!("mid-{}-{}", g, j),
"alice",
"commit-data",
);
}
}
let buffered: usize = protocol
.group_mesh
.pending_commits
.values()
.map(|b| b.len())
.sum();
assert_eq!(buffered, MAX_PENDING_COMMITS_TOTAL);
assert_eq!(
protocol.group_mesh.pending_commits.len(),
groups,
"Largest-buffer-first eviction spreads the cost across the flood's own buffers"
);
assert!(protocol
.group_mesh
.pending_commits
.contains_key(&format!("commit-group-{}", groups - 1)));
}
#[test]
fn test_pending_commit_spread_flood_bounded_by_group_cap() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
for i in 0..(MAX_PENDING_COMMIT_GROUPS + 4) {
protocol.buffer_pending_commit(
&format!("spread-commit-{}", i),
&format!("mid-{}", i),
"alice",
"commit-data",
);
}
assert_eq!(
protocol.group_mesh.pending_commits.len(),
MAX_PENDING_COMMIT_GROUPS,
"Distinct buffered commit groups must be capped"
);
}
#[test]
fn test_oversized_pending_commit_rejected_without_purging_buffer() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.buffer_pending_commit("honest-group", "mid-honest", "alice", "small-commit");
let oversized = "x".repeat(MAX_PENDING_COMMIT_TOTAL_BYTES + 1);
protocol.buffer_pending_commit("attacker-group", "mid-oversized", "mallory", &oversized);
assert!(
!protocol
.group_mesh
.pending_commits
.contains_key("attacker-group"),
"An entry exceeding the global byte budget must not be buffered"
);
assert_eq!(
protocol
.group_mesh
.pending_commits
.get("honest-group")
.map(|b| b.len()),
Some(1),
"Rejecting an oversized entry must not evict existing buffered commits"
);
}
#[test]
fn test_global_eviction_prefers_largest_buffer_over_older_honest_entry() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
protocol.buffer_pending_group_message(
"honest-group",
PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: "honest-1".to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now() - StdDuration::from_secs(60),
received_via: None,
},
);
let flood_groups = MAX_PENDING_GROUP_MESSAGES_TOTAL / MAX_PENDING_GROUP_MESSAGES_PER_GROUP + 4;
for g in 0..flood_groups {
for i in 0..MAX_PENDING_GROUP_MESSAGES_PER_GROUP {
protocol.buffer_pending_group_message(
&format!("attacker-group-{}", g),
PendingGroupMessage {
logical_id: None,
sender: "mallory".to_string(),
message_id: format!("atk-{}-{}", g, i),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now(),
received_via: None,
},
);
}
}
let buffered: usize = protocol
.group_mesh
.pending_group_messages
.values()
.map(|b| b.len())
.sum();
assert!(buffered <= MAX_PENDING_GROUP_MESSAGES_TOTAL);
assert_eq!(
protocol
.group_mesh
.pending_group_messages
.get("honest-group")
.map(|b| b.len()),
Some(1),
"The old honest entry must survive a flood of newer entries in larger buffers"
);
}
#[test]
fn test_group_message_buffer_survives_failed_welcome_join() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let msg_json = make_group_mls_msg_json(&alice, &group_id, "hello race");
let wire = make_message(&id("alice"), &id("bob"), "unused-envelope");
bob.handle_group_mls_msg(&wire, &id("alice"), &msg_json);
let bad_welcome_json = serde_json::json!({
"group_id": group_id,
"group_name": "Race Group",
"welcome_data": base64_encode(b"not-a-real-welcome"),
"member_list": [&id("alice"), &id("bob")],
})
.to_string();
bob.handle_group_mls_welcome("bad-welcome", &id("alice"), &bad_welcome_json);
assert!(group_messages_received(&events).is_empty());
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(1),
"A failed Welcome join must leave the buffered message in place"
);
bob.handle_group_mls_welcome("good-welcome", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(received.len(), 1);
assert_eq!(received[0].0, "hello race");
}
#[test]
fn test_leave_group_clears_pending_buffers() {
let (mut protocol, _events) = setup_started_with_events();
let info = protocol.create_group("Leave Cleanup").unwrap();
let group_id = info.group_id.as_str().to_string();
protocol.buffer_pending_commit(&group_id, "mid-stale", &id("alice"), "stale-commit");
protocol.buffer_pending_group_message(
&group_id,
PendingGroupMessage {
logical_id: None,
sender: id("alice"),
message_id: "stale-msg".to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now(),
received_via: None,
},
);
protocol.leave_group(&group_id).unwrap();
assert!(
!protocol.group_mesh.pending_commits.contains_key(&group_id),
"Leaving a group must drop its buffered commits"
);
assert!(
!protocol
.group_mesh
.pending_group_messages
.contains_key(&group_id),
"Leaving a group must drop its buffered messages"
);
}
#[test]
fn test_relay_group_message_before_welcome_buffered_via_dispatch() {
let (alice, mut bob, events, group_id, welcome_json) = setup_race_alice_bob();
let ciphertext_b64 = {
let mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
base64_encode(
&mls.encrypt_for_group(&gid, b"dispatch race")
.unwrap()
.ciphertext,
)
};
let payload = serde_json::json!({
"group_id": group_id,
"sender": &id("alice"),
"content": ciphertext_b64,
"timestamp": "2026-07-10T00:00:00Z",
"message_id": "relay-dispatch-race-1",
});
let content = format!("{}{}", internal_prefixes::GROUP_MSG, payload);
let message = make_message(&id("relay"), &id("bob"), &content);
let result = bob.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
assert!(
group_messages_received(&events).is_empty(),
"Ciphertext must not be emitted raw before the Welcome"
);
assert_eq!(
bob.group_mesh
.pending_group_messages
.get(&group_id)
.map(|b| b.len()),
Some(1),
"Relay message arriving before the Welcome must be buffered by the dispatch path"
);
bob.handle_group_mls_welcome("welcome-dispatch-1", &id("alice"), &welcome_json);
let received = group_messages_received(&events);
assert_eq!(received.len(), 1);
assert_eq!(received[0].0, "dispatch race");
assert_eq!(received[0].1, "relay-dispatch-race-1");
assert_eq!(received[0].2, "2026-07-10T00:00:00Z");
}
#[test]
fn test_relay_group_message_legacy_base64_plaintext_emitted_raw_via_dispatch() {
let (mut protocol, events) = setup_with_events();
let payload = serde_json::json!({
"group_id": "group:legacy-relay-1",
"sender": &id("alice"),
"content": "aGVsbG8=",
"timestamp": "2026-07-10T00:00:00Z",
"message_id": "legacy-b64-1",
});
let content = format!("{}{}", internal_prefixes::GROUP_MSG, payload);
let message = make_message(&id("relay"), &id("user123"), &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let received = group_messages_received(&events);
assert_eq!(
received.len(),
1,
"Legacy base64-looking plaintext must be emitted raw"
);
assert_eq!(received[0].0, "aGVsbG8=");
assert_eq!(received[0].1, "legacy-b64-1");
assert!(
protocol.group_mesh.pending_group_messages.is_empty(),
"Non-MLS content must not occupy the welcome-race buffer"
);
}
#[test]
fn test_relay_group_message_without_mls_emitted_raw_via_dispatch() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
let payload = serde_json::json!({
"group_id": "group:relay-only-1",
"sender": "alice",
"content": "aGVsbG8=",
"timestamp": "2026-07-10T00:00:00Z",
"message_id": "relay-only-1",
});
let content = format!("{}{}", internal_prefixes::GROUP_MSG, payload);
let message = make_message(&id("relay"), "user123", &content);
let result = protocol.process_internal_message(&message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let received = group_messages_received(&events);
assert_eq!(received.len(), 1);
assert_eq!(received[0].0, "aGVsbG8=");
assert!(protocol.group_mesh.pending_group_messages.is_empty());
}
#[test]
fn test_mesh_group_message_non_mls_payload_not_buffered() {
let (mut protocol, events) = setup_with_events();
let msg_json = serde_json::json!({
"group_id": "group:garbage-1",
"ciphertext": base64_encode(b"definitely not mls"),
"epoch": 0,
})
.to_string();
let wire = make_message(&id("mallory"), &id("user123"), "unused-envelope");
let result = protocol.handle_group_mls_msg(&wire, &id("mallory"), &msg_json);
assert!(matches!(result, InternalMessageResult::Consumed));
assert!(group_messages_received(&events).is_empty());
assert!(
protocol.group_mesh.pending_group_messages.is_empty(),
"Non-MLS payloads must not occupy the welcome-race buffer"
);
}
#[test]
fn test_evicted_pending_group_message_releases_transport_dedup() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let ids: Vec<offline_protocol_core::MessageId> = (0..(MAX_PENDING_GROUP_MESSAGES_PER_GROUP
+ 1))
.map(|_| offline_protocol_core::MessageId::new())
.collect();
for mid in &ids {
protocol.deduplicator.mark_seen(mid.clone());
protocol
.group_mesh
.message_dedup
.insert(mid.as_str().to_string(), Instant::now());
protocol.buffer_pending_group_message(
"transport-evict-group",
PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: mid.as_str().to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now(),
received_via: None,
},
);
}
assert!(
!protocol.deduplicator.is_duplicate(&ids[0]),
"Evicting a buffered message must release its transport-level dedup entry"
);
assert!(
protocol.deduplicator.is_duplicate(&ids[1]),
"Surviving buffered messages keep their transport-level dedup entry"
);
assert!(!protocol
.group_mesh
.message_dedup
.contains_key(&ids[0].as_str().to_string()));
}
#[test]
fn test_drain_expired_pending_entries_release_transport_dedup() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let msg_mid = offline_protocol_core::MessageId::new();
let commit_mid = offline_protocol_core::MessageId::new();
protocol.deduplicator.mark_seen(msg_mid.clone());
protocol.deduplicator.mark_seen(commit_mid.clone());
protocol
.group_mesh
.pending_group_messages
.entry("transport-ttl-group".to_string())
.or_default()
.push_back(PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: msg_mid.as_str().to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now()
- StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS + 10),
received_via: None,
});
protocol
.group_mesh
.pending_commits
.entry("transport-ttl-group".to_string())
.or_default()
.push_back(PendingCommit {
sender: "alice".to_string(),
message_id: commit_mid.as_str().to_string(),
data: "commit-data".to_string(),
buffered_at: Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10),
retry_count: 0,
});
protocol.drain_pending_group_messages("transport-ttl-group");
protocol.drain_pending_commits("transport-ttl-group");
assert!(
!protocol.deduplicator.is_duplicate(&msg_mid),
"Drain-expired message must release its transport-level dedup entry"
);
assert!(
!protocol.deduplicator.is_duplicate(&commit_mid),
"Drain-expired commit must release its transport-level dedup entry"
);
}
#[test]
fn test_cleanup_sweep_releases_transport_dedup_for_expired_entries() {
let mut protocol = OfflineProtocol::new(create_test_config()).unwrap();
let commit_mid = offline_protocol_core::MessageId::new();
let msg_mid = offline_protocol_core::MessageId::new();
protocol.deduplicator.mark_seen(commit_mid.clone());
protocol.deduplicator.mark_seen(msg_mid.clone());
protocol
.group_mesh
.pending_commits
.entry("sweep-transport-group".to_string())
.or_default()
.push_back(PendingCommit {
sender: "alice".to_string(),
message_id: commit_mid.as_str().to_string(),
data: "commit-data".to_string(),
buffered_at: Instant::now() - StdDuration::from_secs(PENDING_COMMIT_TTL_SECS + 10),
retry_count: 0,
});
protocol
.group_mesh
.pending_group_messages
.entry("sweep-transport-group".to_string())
.or_default()
.push_back(PendingGroupMessage {
logical_id: None,
sender: "alice".to_string(),
message_id: msg_mid.as_str().to_string(),
ciphertext_b64: base64_encode(b"x"),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now()
- StdDuration::from_secs(PENDING_GROUP_MESSAGE_TTL_SECS + 10),
received_via: None,
});
protocol.cleanup_group_message_dedup();
assert!(protocol.group_mesh.pending_commits.is_empty());
assert!(protocol.group_mesh.pending_group_messages.is_empty());
assert!(
!protocol.deduplicator.is_duplicate(&commit_mid),
"Sweep-expired commit must release its transport-level dedup entry"
);
assert!(
!protocol.deduplicator.is_duplicate(&msg_mid),
"Sweep-expired message must release its transport-level dedup entry"
);
}
fn group_cloud_media_original(sender: &str, recipient: &str) -> offline_protocol_core::Message {
let mut original = make_message(sender, recipient, "check out this photo");
original.content_type = offline_protocol_core::ContentType::Image;
original.media_metadata = Some(offline_protocol_core::MediaMetadata {
mime_type: "image/jpeg".to_string(),
file_name: "secret-photo.jpg".to_string(),
file_size: 42,
duration_ms: None,
width: Some(10),
height: Some(10),
thumbnail_base64: None,
media_id: None,
download_url: Some("https://cdn.example/blob/1".to_string()),
thumbnail_url: None,
encryption_key: Some("a2V5LWJ5dGVz".to_string()),
iv: Some("aXYtYnl0ZXM=".to_string()),
ciphertext_hash: None,
sticker_provider: None,
sticker_remote_id: None,
sticker_kind: None,
});
original
}
fn wire_mock_transport(
protocol: &mut OfflineProtocol,
) -> offline_protocol_transport::MockTransport {
let mock = offline_protocol_transport::MockTransport::new(TransportType::BLE);
mock.start().unwrap();
let handle = mock.clone();
protocol
.transport_manager_mut()
.add_transport(TransportType::BLE, Box::new(mock));
handle
}
#[test]
fn group_forward_seals_media_secrets_when_all_members_capable() {
let (mut alice, mut bob, group_id) = setup_alice_bob_group("Rich Forward Group");
let alice_handle = wire_mock_transport(&mut alice);
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let original = group_cloud_media_original(&id("dave"), &id("alice"));
alice
.forward_message_to_group(&original, &group_id, None)
.unwrap();
let sent = alice_handle.sent_messages();
let wire = sent
.iter()
.find(|m| m.content.starts_with(internal_prefixes::GROUP_MLS_MSG))
.expect("group forward must reach the wire");
assert!(!wire.content.contains("a2V5LWJ5dGVz"));
assert!(!wire.content.contains("https://cdn.example/blob/1"));
assert!(
!wire.content.contains(&id("dave")),
"sealed forward must not carry hop-visible attribution"
);
let bob_message = make_message(&id("alice"), &id("bob"), &wire.content);
let result = bob.process_internal_message(&bob_message);
assert!(matches!(result, Some(InternalMessageResult::Consumed)));
let events = bob_events.lock().unwrap();
let received = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }))
.expect("bob must receive the forwarded group message");
let Event::GroupMessageReceived {
content,
media_metadata,
content_type,
forward_info,
..
} = received
else {
unreachable!()
};
assert_eq!(content, "check out this photo");
let media = media_metadata
.as_ref()
.expect("sealed media metadata restored");
assert_eq!(media.encryption_key.as_deref(), Some("a2V5LWJ5dGVz"));
assert_eq!(media.iv.as_deref(), Some("aXYtYnl0ZXM="));
assert_eq!(
media.download_url.as_deref(),
Some("https://cdn.example/blob/1")
);
assert_eq!(content_type.as_deref(), Some("image"));
let fwd = forward_info
.as_ref()
.expect("forward attribution restored from the sealed body");
assert_eq!(fwd.original_sender, id("dave"));
}
#[test]
fn group_forward_drops_media_when_member_capability_unknown() {
let (mut alice, mut bob, group_id) = setup_alice_bob_group("Legacy Member Group");
let alice_handle = wire_mock_transport(&mut alice);
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let alice_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let alice_events_clone = alice_events.clone();
alice.on_event(move |event| {
alice_events_clone.lock().unwrap().push(event);
});
let original = group_cloud_media_original(&id("dave"), &id("alice"));
alice
.forward_message_to_group(&original, &group_id, None)
.unwrap();
assert!(
alice_events.lock().unwrap().iter().any(
|e| matches!(e, Event::GroupRichExtrasDropped { group_id: g, .. } if g == &group_id)
),
"dropping rich media must emit GroupRichExtrasDropped to the sender"
);
let sent = alice_handle.sent_messages();
let wire = sent
.iter()
.find(|m| m.content.starts_with(internal_prefixes::GROUP_MLS_MSG))
.expect("group forward must reach the wire");
assert!(!wire.content.contains("a2V5LWJ5dGVz"));
assert!(!wire.content.contains(internal_prefixes::RICH_V1));
let bob_message = make_message(&id("alice"), &id("bob"), &wire.content);
bob.process_internal_message(&bob_message);
let events = bob_events.lock().unwrap();
let received = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }))
.expect("bob must still receive the forwarded text");
let Event::GroupMessageReceived {
content,
media_metadata,
forward_info,
..
} = received
else {
unreachable!()
};
assert_eq!(content, "check out this photo");
assert!(
media_metadata.is_none(),
"media metadata must drop toward a not-fully-capable group"
);
let fwd = forward_info
.as_ref()
.expect("payload attribution survives for legacy groups");
assert_eq!(fwd.original_sender, id("dave"));
}
#[test]
fn group_send_with_seals_media_toward_capable_group() {
let (mut alice, mut bob, group_id) = setup_alice_bob_group("Rich Send Group");
let alice_handle = wire_mock_transport(&mut alice);
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let media = group_cloud_media_original(&id("alice"), &id("bob"))
.media_metadata
.unwrap();
alice
.send_group_message_with(
&group_id,
"fresh cloud photo",
GroupSendOptions {
content_type: Some(offline_protocol_core::ContentType::Image),
media_metadata: Some(media),
..Default::default()
},
)
.unwrap();
let sent = alice_handle.sent_messages();
let wire = sent
.iter()
.find(|m| m.content.starts_with(internal_prefixes::GROUP_MLS_MSG))
.expect("group send must reach the wire");
assert!(!wire.content.contains("a2V5LWJ5dGVz"));
let bob_message = make_message(&id("alice"), &id("bob"), &wire.content);
bob.process_internal_message(&bob_message);
let events = bob_events.lock().unwrap();
let Some(Event::GroupMessageReceived {
content,
media_metadata,
content_type,
..
}) = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }))
else {
panic!("bob must receive the rich group message");
};
assert_eq!(content, "fresh cloud photo");
assert_eq!(
media_metadata
.as_ref()
.and_then(|m| m.encryption_key.as_deref()),
Some("a2V5LWJ5dGVz")
);
assert_eq!(content_type.as_deref(), Some("image"));
}
#[test]
fn group_relay_path_restores_sealed_media() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Relay Rich Group");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let original = group_cloud_media_original(&id("dave"), &id("alice"));
let sealed = OfflineProtocol::seal_rich_payload(
&original.content,
&crate::protocol::RichSendExtras {
reply_context: None,
media_metadata: original.media_metadata.clone(),
forward_info: Some(offline_protocol_core::ForwardInfo::from_message(&original)),
},
original.content_type,
)
.unwrap();
let encrypted = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.encrypt_for_group(&gid, sealed.as_bytes())
.unwrap()
};
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&base64_encode(&encrypted.ciphertext),
"2026-07-21T00:00:00Z",
"relay-msg-1",
None,
None,
);
let events = bob_events.lock().unwrap();
let Some(Event::GroupMessageReceived {
content,
media_metadata,
forward_info,
..
}) = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }))
else {
panic!("relay rich message must surface");
};
assert_eq!(content, "check out this photo");
assert_eq!(
media_metadata
.as_ref()
.and_then(|m| m.encryption_key.as_deref()),
Some("a2V5LWJ5dGVz")
);
assert_eq!(
forward_info.as_ref().map(|f| f.original_sender.as_str()),
Some(id("dave").as_str())
);
}
#[test]
fn group_sealed_parse_failure_surfaces_raw_text() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Malformed Rich Group");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let raw = format!("{}not-json", internal_prefixes::RICH_V1);
let encrypted = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls.encrypt_for_group(&gid, raw.as_bytes()).unwrap()
};
let msg_payload = GroupMlsMessagePayload {
message_id: None,
group_id: group_id.clone(),
ciphertext: base64_encode(&encrypted.ciphertext),
epoch: encrypted.epoch,
reply_to: None,
forward_info: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_MSG,
serde_json::to_string(&msg_payload).unwrap()
);
bob.process_internal_message(&make_message(&id("alice"), &id("bob"), &content));
let events = bob_events.lock().unwrap();
let Some(Event::GroupMessageReceived {
content,
media_metadata,
..
}) = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }))
else {
panic!("malformed rich body must still surface");
};
assert_eq!(content, &raw);
assert!(media_metadata.is_none());
}
#[test]
fn group_send_with_rejects_file_chunk_and_oversized_extras() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Boundary Group");
let err = alice
.send_group_message_with(
&group_id,
"x",
GroupSendOptions {
content_type: Some(offline_protocol_core::ContentType::FileChunk),
..Default::default()
},
)
.unwrap_err();
assert!(matches!(err, crate::Error::InvalidArgument(_)));
let mut original = group_cloud_media_original(&id("dave"), &id("alice"));
original.content_type = offline_protocol_core::ContentType::FileChunk;
let err = alice
.forward_message_to_group(&original, &group_id, None)
.unwrap_err();
assert!(matches!(err, crate::Error::InvalidArgument(_)));
let mut original = group_cloud_media_original(&id("dave"), &id("alice"));
if let Some(media) = original.media_metadata.as_mut() {
media.thumbnail_base64 = Some("x".repeat(crate::protocol::MAX_RICH_EXTRAS_BYTES + 1));
}
let err = alice
.forward_message_to_group(&original, &group_id, None)
.unwrap_err();
assert!(err.to_string().contains("Rich extras too large"));
}
#[test]
fn group_sealed_body_ignores_injected_payload_attribution() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Injected Attribution Group");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let original = group_cloud_media_original(&id("dave"), &id("alice"));
let sealed = OfflineProtocol::seal_rich_payload(
&original.content,
&crate::protocol::RichSendExtras {
reply_context: None,
media_metadata: original.media_metadata.clone(),
forward_info: None,
},
original.content_type,
)
.unwrap();
let encrypted = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.encrypt_for_group(&gid, sealed.as_bytes())
.unwrap()
};
let injected = offline_protocol_core::ForwardInfo::from_message(&make_message(
&id("mallory"),
&id("bob"),
"never sent",
));
bob.handle_relay_group_message_with_mls(
&group_id,
&id("alice"),
&base64_encode(&encrypted.ciphertext),
"2026-07-22T00:00:00Z",
"relay-injected-1",
None,
Some(injected),
);
let events = bob_events.lock().unwrap();
let Some(Event::GroupMessageReceived {
content,
media_metadata,
forward_info,
..
}) = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }))
else {
panic!("sealed rich message must surface");
};
assert_eq!(content, "check out this photo");
assert!(
media_metadata.is_some(),
"sealed media metadata must restore"
);
assert!(
forward_info.is_none(),
"injected payload attribution must be ignored when the sealed body parsed"
);
}
#[test]
fn group_buffered_drain_restores_sealed_media() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Buffered Rich Group");
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let original = group_cloud_media_original(&id("dave"), &id("alice"));
let sealed = OfflineProtocol::seal_rich_payload(
&original.content,
&crate::protocol::RichSendExtras {
reply_context: None,
media_metadata: original.media_metadata.clone(),
forward_info: Some(offline_protocol_core::ForwardInfo::from_message(&original)),
},
original.content_type,
)
.unwrap();
let encrypted = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.encrypt_for_group(&gid, sealed.as_bytes())
.unwrap()
};
bob.buffer_pending_group_message(
&group_id,
PendingGroupMessage {
logical_id: None,
sender: id("alice"),
message_id: "buffered-rich-1".to_string(),
ciphertext_b64: base64_encode(&encrypted.ciphertext),
timestamp: None,
reply_to: None,
forward_info: None,
buffered_at: Instant::now(),
received_via: None,
},
);
bob.drain_pending_group_messages(&group_id);
let events = bob_events.lock().unwrap();
let Some(Event::GroupMessageReceived {
content,
media_metadata,
content_type,
forward_info,
..
}) = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }))
else {
panic!("drained rich message must surface");
};
assert_eq!(content, "check out this photo");
assert_eq!(
media_metadata
.as_ref()
.and_then(|m| m.encryption_key.as_deref()),
Some("a2V5LWJ5dGVz")
);
assert_eq!(content_type.as_deref(), Some("image"));
assert_eq!(
forward_info.as_ref().map(|f| f.original_sender.as_str()),
Some(id("dave").as_str())
);
}
#[test]
fn group_hint_only_send_seals_content_type_toward_capable_group() {
let (mut alice, mut bob, group_id) = setup_alice_bob_group("Hint Only Group");
let alice_handle = wire_mock_transport(&mut alice);
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
alice
.send_group_message_with(
&group_id,
"legacy voice note",
GroupSendOptions {
content_type: Some(offline_protocol_core::ContentType::VoiceNote),
..Default::default()
},
)
.unwrap();
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
alice
.send_group_message_with(
&group_id,
"sealed voice note",
GroupSendOptions {
content_type: Some(offline_protocol_core::ContentType::VoiceNote),
..Default::default()
},
)
.unwrap();
for wire in alice_handle.sent_messages() {
if wire.content.starts_with(internal_prefixes::GROUP_MLS_MSG) {
bob.process_internal_message(&make_message(&id("alice"), &id("bob"), &wire.content));
}
}
let events = bob_events.lock().unwrap();
let received: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::GroupMessageReceived {
content,
content_type,
..
} => Some((content.as_str(), content_type.as_deref())),
_ => None,
})
.collect();
assert_eq!(
received,
vec![
("legacy voice note", None),
("sealed voice note", Some("voice_note")),
],
"hint must drop toward a not-fully-capable group and seal once every member is capable"
);
}
#[test]
fn group_rich_seal_gate_requires_every_member_capable() {
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
alice
.initialize_mls_for_test(Arc::new(crate::mls::InMemoryStorage::default()))
.unwrap();
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
let members = vec![id("alice"), id("bob"), id("carol")];
assert!(
!alice.group_rich_seal_active(&members),
"a member with unknown capability must fail the gate for the whole group"
);
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("carol"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
assert!(
alice.group_rich_seal_active(&members),
"gate passes once every non-self member advertised the capability"
);
}
#[test]
fn group_rich_kill_switch_drops_extras_even_when_all_members_capable() {
let (mut alice, mut bob, group_id) = setup_alice_bob_group("Kill Switch Group");
let alice_handle = wire_mock_transport(&mut alice);
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
alice.config.encryption.rich_payload_enabled = false;
let bob_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let bob_events_clone = bob_events.clone();
bob.on_event(move |event| {
bob_events_clone.lock().unwrap().push(event);
});
let alice_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let alice_events_clone = alice_events.clone();
alice.on_event(move |event| {
alice_events_clone.lock().unwrap().push(event);
});
let media = group_cloud_media_original(&id("alice"), &id("bob"))
.media_metadata
.unwrap();
alice
.send_group_message_with(
&group_id,
"cloud photo",
GroupSendOptions {
content_type: Some(offline_protocol_core::ContentType::Image),
media_metadata: Some(media),
..Default::default()
},
)
.unwrap();
let sent = alice_handle.sent_messages();
let wire = sent
.iter()
.find(|m| m.content.starts_with(internal_prefixes::GROUP_MLS_MSG))
.expect("group send must reach the wire");
assert!(!wire.content.contains(internal_prefixes::RICH_V1));
assert!(!wire.content.contains("a2V5LWJ5dGVz"));
bob.process_internal_message(&make_message(&id("alice"), &id("bob"), &wire.content));
let events = bob_events.lock().unwrap();
let Some(Event::GroupMessageReceived {
content,
media_metadata,
content_type,
..
}) = events
.iter()
.find(|e| matches!(e, Event::GroupMessageReceived { .. }))
else {
panic!("bob must receive the plain group message");
};
assert_eq!(content, "cloud photo");
assert!(
media_metadata.is_none(),
"kill switch must drop rich media metadata"
);
assert!(content_type.is_none(), "kill switch must drop the hint");
assert!(
alice_events.lock().unwrap().iter().any(
|e| matches!(e, Event::GroupRichExtrasDropped { group_id: g, .. } if g == &group_id)
),
"kill-switch drop must emit GroupRichExtrasDropped to the sender"
);
}
#[test]
fn group_send_rejects_internal_prefix_content() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Prefix Guard Group");
let spoof = format!("{}{{\"text\":\"fake\"}}", internal_prefixes::RICH_V1);
let err = alice
.send_group_message(&group_id, &spoof, None, None)
.unwrap_err();
assert!(
matches!(err, crate::Error::InvalidArgument(_)),
"internal-prefix group send must be rejected, got: {err:?}"
);
let err = alice
.send_group_message_with(&group_id, &spoof, GroupSendOptions::default())
.unwrap_err();
assert!(
matches!(err, crate::Error::InvalidArgument(_)),
"internal-prefix rich group send must be rejected, got: {err:?}"
);
}
fn setup_three_party_invite(
bob_known: bool,
carol_known: bool,
) -> (OfflineProtocol, OfflineProtocol, OfflineProtocol, String) {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
let mut carol = OfflineProtocol::new(create_test_config_for_user("carol")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
carol.initialize_mls_for_test(storage_c).unwrap();
alice.start().unwrap();
bob.start().unwrap();
carol.start().unwrap();
let group_info = alice.create_group("Attestation Group").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
use crate::protocol::ReceivedKeyPackage;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: bob_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
if bob_known {
crate::protocol::tests::feed_key_package_with_capabilities(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
vec![crate::protocol::DATA_SYNC_V1, DATA_GROUP_V1],
);
alice.pending_key_packages.insert(
id("bob"),
ReceivedKeyPackage {
key_package_data: {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap().key_package_data
},
local_expires_at_ms: now_ms + 600_000,
},
);
}
alice.invite_to_group(&group_id, &id("bob")).unwrap();
let bob_welcome = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
})
.expect("alice must have queued bob's Welcome")
.clone();
bob.process_internal_message(&make_message(
&id("alice"),
&id("bob"),
&bob_welcome.content,
));
assert!(
bob.group_mesh.members.contains_key(&group_id),
"bob must have joined via the Welcome"
);
let carol_kp = {
let carol_mls = carol.mls_manager_for_testing().read().unwrap();
carol_mls.generate_key_package().unwrap()
};
if carol_known {
crate::protocol::tests::feed_key_package_with_capabilities(
&mut alice,
&id("carol"),
vec![crate::protocol::RICH_PAYLOAD_V1],
vec![crate::protocol::DATA_SYNC_V1, DATA_GROUP_V1],
);
}
alice.pending_key_packages.insert(
id("carol"),
ReceivedKeyPackage {
key_package_data: carol_kp.key_package_data,
local_expires_at_ms: now_ms + 600_000,
},
);
alice.clear_outbox();
alice.invite_to_group(&group_id, &id("carol")).unwrap();
(alice, bob, carol, group_id)
}
#[test]
fn invite_attests_rich_capability_on_commit_and_welcome() {
let (alice, _bob, _carol, _group_id) = setup_three_party_invite(true, true);
let commit = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
})
.expect("commit to bob must be queued");
let commit_payload: GroupMlsCommitPayload = serde_json::from_str(
commit
.content
.strip_prefix(internal_prefixes::GROUP_MLS_COMMIT)
.unwrap(),
)
.unwrap();
assert_eq!(
commit_payload.affected_member_rich,
Some(vec![crate::protocol::RICH_PAYLOAD_V1]),
"the commit must attest the invitee's known rich capability"
);
let welcome = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
})
.expect("welcome to carol must be queued");
let welcome_payload: GroupMlsWelcomePayload = serde_json::from_str(
welcome
.content
.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME)
.unwrap(),
)
.unwrap();
assert_eq!(
welcome_payload.member_rich.get(&id("alice")),
Some(&vec![crate::protocol::RICH_PAYLOAD_V1]),
"the welcome must self-attest the inviter"
);
assert_eq!(
welcome_payload.member_rich.get(&id("bob")),
Some(&vec![crate::protocol::RICH_PAYLOAD_V1]),
"the welcome must attest known existing members"
);
assert!(
!welcome_payload.member_rich.contains_key(&id("carol")),
"the joiner needs no entry about itself"
);
}
#[test]
fn invite_omits_attestation_for_unknown_members() {
let (alice, _bob, _carol, _group_id) = setup_three_party_invite(false, false);
let commit = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
})
.expect("commit to bob must be queued");
let commit_payload: GroupMlsCommitPayload = serde_json::from_str(
commit
.content
.strip_prefix(internal_prefixes::GROUP_MLS_COMMIT)
.unwrap(),
)
.unwrap();
assert_eq!(commit_payload.affected_member_rich, None);
let welcome = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
})
.expect("welcome to carol must be queued");
let welcome_payload: GroupMlsWelcomePayload = serde_json::from_str(
welcome
.content
.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME)
.unwrap(),
)
.unwrap();
assert_eq!(
welcome_payload.member_rich.get(&id("alice")),
Some(&vec![crate::protocol::RICH_PAYLOAD_V1]),
"the inviter still self-attests"
);
assert!(!welcome_payload.member_rich.contains_key(&id("bob")));
}
#[test]
fn commit_attestation_teaches_existing_member() {
let (alice, mut bob, _carol, _group_id) = setup_three_party_invite(true, true);
let members = vec![id("alice"), id("bob"), id("carol")];
assert!(!bob.group_rich_seal_active(&members));
let commit = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
})
.expect("commit to bob must be queued")
.clone();
bob.process_internal_message(&make_message(&id("alice"), &id("bob"), &commit.content));
assert!(
bob.group_rich_seal_active(&members),
"the admin's attested commit must teach bob the newcomer's capability"
);
}
#[test]
fn welcome_attestation_lets_joiner_seal_and_ignores_non_roster_entries() {
let (alice, _bob, mut carol, group_id) = setup_three_party_invite(true, true);
let welcome = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
})
.expect("welcome to carol must be queued")
.clone();
let mut payload: GroupMlsWelcomePayload = serde_json::from_str(
welcome
.content
.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME)
.unwrap(),
)
.unwrap();
payload
.member_rich
.insert(id("mallory"), vec![crate::protocol::RICH_PAYLOAD_V1]);
let tampered = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&payload).unwrap()
);
carol.process_internal_message(&make_message(&id("alice"), &id("carol"), &tampered));
assert!(
carol.group_mesh.members.contains_key(&group_id),
"carol must have joined via the Welcome"
);
let members = vec![id("alice"), id("bob"), id("carol")];
assert!(
carol.group_rich_seal_active(&members),
"the welcome attestation must let the joiner seal toward members it \
never directly exchanged with"
);
assert!(
!carol.group_rich_seal_active(&vec![id("carol"), id("mallory")]),
"a non-roster map entry must not be recorded"
);
}
#[test]
fn non_admin_commit_attestation_is_ignored() {
let (alice, mut bob, _carol, group_id) = setup_three_party_invite(true, true);
let members = vec![id("alice"), id("bob"), id("carol")];
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Member)
.unwrap();
bob_mls
.set_member_role(&gid, &id("bob"), GroupRole::Admin)
.unwrap();
}
let commit = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
})
.expect("commit to bob must be queued")
.clone();
bob.process_internal_message(&make_message(&id("alice"), &id("bob"), &commit.content));
assert!(
!bob.group_rich_seal_active(&members),
"an attestation from a non-admin sender must be ignored"
);
}
#[test]
fn invite_attests_group_replication_on_commit_and_welcome() {
let (alice, _bob, _carol, _group_id) = setup_three_party_invite(true, true);
let commit = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
})
.expect("commit to bob must be queued");
let commit_payload: GroupMlsCommitPayload = serde_json::from_str(
commit
.content
.strip_prefix(internal_prefixes::GROUP_MLS_COMMIT)
.unwrap(),
)
.unwrap();
assert_eq!(
commit_payload.affected_member_data,
Some(vec![DATA_GROUP_V1]),
"the commit must tell the existing members what the newcomer \
supports, or the group's gate stays closed on her account"
);
let welcome = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
})
.expect("welcome to carol must be queued");
let welcome_payload: GroupMlsWelcomePayload = serde_json::from_str(
welcome
.content
.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME)
.unwrap(),
)
.unwrap();
assert_eq!(
welcome_payload.member_data.get(&id("alice")),
Some(&vec![DATA_GROUP_V1]),
"the welcome must self-attest the inviter"
);
assert_eq!(
welcome_payload.member_data.get(&id("bob")),
Some(&vec![DATA_GROUP_V1]),
"Carol will never exchange key packages with Bob, so this map is the \
only way she learns he replicates"
);
assert!(
!welcome_payload.member_data.contains_key(&id("carol")),
"the joiner needs no entry about itself"
);
}
#[test]
fn invite_omits_group_replication_attestation_for_unknown_members() {
let (alice, _bob, _carol, _group_id) = setup_three_party_invite(false, false);
let commit = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
})
.expect("commit to bob must be queued");
let commit_payload: GroupMlsCommitPayload = serde_json::from_str(
commit
.content
.strip_prefix(internal_prefixes::GROUP_MLS_COMMIT)
.unwrap(),
)
.unwrap();
assert_eq!(commit_payload.affected_member_data, None);
let welcome = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
})
.expect("welcome to carol must be queued");
let welcome_payload: GroupMlsWelcomePayload = serde_json::from_str(
welcome
.content
.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME)
.unwrap(),
)
.unwrap();
assert_eq!(
welcome_payload.member_data.get(&id("alice")),
Some(&vec![DATA_GROUP_V1]),
"the inviter still self-attests from what it actually advertises"
);
assert!(!welcome_payload.member_data.contains_key(&id("bob")));
}
#[test]
fn commit_attestation_opens_the_group_replication_gate() {
let (alice, mut bob, _carol, _group_id) = setup_three_party_invite(true, true);
let members = vec![id("alice"), id("bob"), id("carol")];
assert!(
!bob.group_data_sync_active(&members),
"precondition: carol is unknown to bob until the commit arrives"
);
let commit = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
})
.expect("commit to bob must be queued")
.clone();
bob.process_internal_message(&make_message(&id("alice"), &id("bob"), &commit.content));
assert!(
bob.group_data_sync_active(&members),
"the admin's attested commit must teach bob that the newcomer \
replicates, or the group quietly stops converging for everyone"
);
}
#[test]
fn welcome_group_replication_attestation_is_bounded_to_the_roster() {
let (alice, _bob, mut carol, group_id) = setup_three_party_invite(true, true);
let welcome = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
})
.expect("welcome to carol must be queued")
.clone();
let mut payload: GroupMlsWelcomePayload = serde_json::from_str(
welcome
.content
.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME)
.unwrap(),
)
.unwrap();
payload
.member_data
.insert(id("mallory"), vec![DATA_GROUP_V1]);
let tampered = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&payload).unwrap()
);
carol.process_internal_message(&make_message(&id("alice"), &id("carol"), &tampered));
assert!(
carol.group_mesh.members.contains_key(&group_id),
"carol must have joined via the Welcome"
);
assert!(
carol.group_data_sync_active(&vec![id("alice"), id("bob"), id("carol")]),
"the welcome attestation must let the joiner replicate with members \
it never directly exchanged with"
);
assert!(
!carol.group_data_sync_active(&vec![id("carol"), id("mallory")]),
"a non-roster map entry must not be recorded"
);
}
#[test]
fn the_inviter_self_attests_exactly_what_it_advertises() {
let (alice, _bob, _carol, _group_id) = setup_three_party_invite(true, true);
let advertises_group = alice.advertised_data_versions().contains(&DATA_GROUP_V1);
let welcome = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_WELCOME)
})
.expect("welcome to carol must be queued");
let welcome_payload: GroupMlsWelcomePayload = serde_json::from_str(
welcome
.content
.strip_prefix(internal_prefixes::GROUP_MLS_WELCOME)
.unwrap(),
)
.unwrap();
assert_eq!(
welcome_payload.member_data.contains_key(&id("alice")),
advertises_group,
"the inviter's self-attestation and its own advertisement have to be \
the same claim; a build that attests what it does not advertise is \
telling the group to send it frames it will render as chat text"
);
}
#[test]
fn non_admin_commit_group_replication_attestation_is_ignored() {
let (alice, mut bob, _carol, group_id) = setup_three_party_invite(true, true);
let members = vec![id("alice"), id("bob"), id("carol")];
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Member)
.unwrap();
bob_mls
.set_member_role(&gid, &id("bob"), GroupRole::Admin)
.unwrap();
}
let commit = alice
.outbox_messages()
.find(|m| {
m.recipient.as_str() == &id("bob")
&& m.content.starts_with(internal_prefixes::GROUP_MLS_COMMIT)
})
.expect("commit to bob must be queued")
.clone();
bob.process_internal_message(&make_message(&id("alice"), &id("bob"), &commit.content));
assert!(
!bob.group_data_sync_active(&members),
"an attestation from a non-admin sender must be ignored"
);
}
#[test]
fn group_kill_switch_drop_blames_nobody_and_skips_backfill() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Kill Switch Blame Group");
let alice_handle = wire_mock_transport(&mut alice);
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
alice
.group_mesh
.members
.get_mut(&group_id)
.unwrap()
.push(id("carol"));
alice.config.encryption.rich_payload_enabled = false;
let alice_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let alice_events_clone = alice_events.clone();
alice.on_event(move |event| {
alice_events_clone.lock().unwrap().push(event);
});
let media = group_cloud_media_original(&id("alice"), &id("bob"))
.media_metadata
.unwrap();
alice
.send_group_message_with(
&group_id,
"cloud photo",
GroupSendOptions {
content_type: Some(offline_protocol_core::ContentType::Image),
media_metadata: Some(media),
..Default::default()
},
)
.unwrap();
let events = alice_events.lock().unwrap();
let Some(Event::GroupRichExtrasDropped {
group_id: g,
unknown_members,
}) = events
.iter()
.find(|e| matches!(e, Event::GroupRichExtrasDropped { .. }))
else {
panic!("kill-switch drop must emit GroupRichExtrasDropped");
};
assert_eq!(g, &group_id);
assert!(
unknown_members.is_empty(),
"kill-switch drop must blame nobody, got: {unknown_members:?}"
);
let kp_probes = alice_handle
.sent_messages()
.iter()
.filter(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::KEY_PACKAGE)
})
.count();
assert_eq!(
kp_probes, 0,
"kill-switch drop must not probe capability-unknown members"
);
}
#[test]
fn group_drop_reports_unknown_members_and_backfills_capability() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Backfill Group");
let alice_handle = wire_mock_transport(&mut alice);
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
alice
.group_mesh
.members
.get_mut(&group_id)
.unwrap()
.push(id("carol"));
let alice_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let alice_events_clone = alice_events.clone();
alice.on_event(move |event| {
alice_events_clone.lock().unwrap().push(event);
});
let media = group_cloud_media_original(&id("alice"), &id("bob"))
.media_metadata
.unwrap();
for _ in 0..2 {
alice
.send_group_message_with(
&group_id,
"cloud photo",
GroupSendOptions {
content_type: Some(offline_protocol_core::ContentType::Image),
media_metadata: Some(media.clone()),
..Default::default()
},
)
.unwrap();
}
let events = alice_events.lock().unwrap();
let dropped = events
.iter()
.find(|e| matches!(e, Event::GroupRichExtrasDropped { .. }))
.expect("drop event must be emitted");
let Event::GroupRichExtrasDropped {
group_id: g,
unknown_members,
} = dropped
else {
unreachable!()
};
assert_eq!(g, &group_id);
assert_eq!(
unknown_members,
&vec![id("carol")],
"only the capability-unknown member is reported — bob is known capable"
);
let kp_probes = alice_handle
.sent_messages()
.iter()
.filter(|m| {
m.recipient.as_str() == &id("carol")
&& m.content.starts_with(internal_prefixes::KEY_PACKAGE)
})
.count();
assert_eq!(
kp_probes, 1,
"backfill must probe the unknown member exactly once across repeated sends"
);
}
#[test]
fn group_rich_readiness_reports_gate_state() {
let (mut alice, _bob, group_id) = setup_alice_bob_group("Readiness Group");
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("bob"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
alice
.group_mesh
.members
.get_mut(&group_id)
.unwrap()
.push(id("carol"));
let readiness = alice.group_rich_readiness(&group_id).unwrap();
assert!(!readiness.ready);
assert_eq!(readiness.unknown_members, vec![id("carol")]);
crate::protocol::tests::feed_key_package_with_rich(
&mut alice,
&id("carol"),
vec![crate::protocol::RICH_PAYLOAD_V1],
);
let readiness = alice.group_rich_readiness(&group_id).unwrap();
assert!(readiness.ready);
assert!(readiness.unknown_members.is_empty());
alice.config.encryption.rich_payload_enabled = false;
let readiness = alice.group_rich_readiness(&group_id).unwrap();
assert!(!readiness.ready);
assert!(readiness.unknown_members.is_empty());
assert!(matches!(
alice.group_rich_readiness("no-such-group"),
Err(crate::Error::GroupNotFound(_))
));
}
fn setup_alice_bob_charlie_group(group_name: &str) -> (OfflineProtocol, OfflineProtocol, String) {
let (mut alice, bob, group_id) = setup_alice_bob_group(group_name);
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut charlie = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
charlie.initialize_mls_for_test(storage_c).unwrap();
let charlie_kp = {
let charlie_mls = charlie.mls_manager_for_testing().read().unwrap();
charlie_mls.generate_key_package().unwrap()
};
let commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let (_welcome, commit) = alice_mls
.add_group_member(&gid, &id("charlie"), &charlie_kp.key_package_data)
.unwrap();
commit
};
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.decrypt_from_group(&commit, &id("alice")).unwrap();
}
alice.refresh_group_members(&group_id).unwrap();
(alice, bob, group_id)
}
fn group_commit_frame(
commit: &offline_protocol_mls::EncryptedMessage,
group_id: &str,
commit_type: &str,
affected_member: Option<&str>,
) -> String {
serde_json::json!({
"group_id": group_id,
"commit_type": commit_type,
"ciphertext": base64_encode(&commit.ciphertext),
"epoch": commit.epoch,
"affected_member": affected_member,
})
.to_string()
}
fn group_epoch_of(protocol: &OfflineProtocol, group_id: &str) -> u64 {
let mls = protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(group_id).unwrap();
mls.get_group_info(&gid).unwrap().unwrap().epoch
}
fn collect_events(protocol: &mut OfflineProtocol) -> Arc<Mutex<Vec<Event>>> {
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = events.clone();
protocol.on_event(move |event| {
events_clone.lock().unwrap().push(event);
});
events
}
fn unauthorized_change(
events: &Arc<Mutex<Vec<Event>>>,
) -> Option<(String, Vec<String>, Vec<String>, String)> {
events.lock().unwrap().iter().find_map(|e| match e {
Event::GroupUnauthorizedMembershipChange {
committer,
added,
removed,
reason,
..
} => Some((
committer.clone(),
added.clone(),
removed.clone(),
reason.clone(),
)),
_ => None,
})
}
#[test]
fn test_unauthorized_remove_commit_emits_security_event_and_marks_unauthorized() {
let (mut alice, bob, group_id) = setup_alice_bob_charlie_group("Insider Remove");
let events = collect_events(&mut alice);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("insider-remove-1", &id("bob"), &frame);
let (committer, added, removed, reason) = unauthorized_change(&events)
.expect("a non-admin Remove commit must surface GroupUnauthorizedMembershipChange");
assert_eq!(committer, id("bob"));
assert!(added.is_empty(), "a Remove commit adds nobody");
assert_eq!(removed, vec![id("charlie")]);
assert_eq!(reason, "sender_not_admin");
let removed_event = events.lock().unwrap().iter().find_map(|e| match e {
Event::GroupMemberRemoved {
user_id,
authorized,
..
} => Some((user_id.clone(), *authorized)),
_ => None,
});
assert_eq!(
removed_event,
Some((id("charlie"), Some(false))),
"GroupMemberRemoved must still fire, flagged authorized = false"
);
}
#[test]
fn test_unauthorized_add_commit_emits_security_event_and_marks_unauthorized() {
let (mut alice, bob, group_id) = setup_alice_bob_charlie_group("Insider Add");
let events = collect_events(&mut alice);
let storage_d = Arc::new(crate::mls::InMemoryStorage::default());
let mut dave = OfflineProtocol::new(create_test_config_for_user("dave")).unwrap();
dave.initialize_mls_for_test(storage_d).unwrap();
let dave_kp = {
let dave_mls = dave.mls_manager_for_testing().read().unwrap();
dave_mls.generate_key_package().unwrap()
};
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (_welcome, commit) = bob_mls
.add_group_member(&gid, &id("dave"), &dave_kp.key_package_data)
.unwrap();
commit
};
let frame = group_commit_frame(&commit, &group_id, "add", Some(&id("dave")));
alice.handle_group_mls_commit("insider-add-1", &id("bob"), &frame);
let (committer, added, removed, reason) = unauthorized_change(&events)
.expect("a non-admin Add commit must surface GroupUnauthorizedMembershipChange");
assert_eq!(committer, id("bob"));
assert_eq!(added, vec![id("dave")]);
assert!(removed.is_empty(), "an Add commit removes nobody");
assert_eq!(reason, "sender_not_admin");
let added_event = events.lock().unwrap().iter().find_map(|e| match e {
Event::GroupMemberAdded {
user_id,
authorized,
..
} => Some((user_id.clone(), *authorized)),
_ => None,
});
assert_eq!(
added_event,
Some((id("dave"), Some(false))),
"GroupMemberAdded must still fire, flagged authorized = false"
);
}
#[test]
fn test_admin_commit_does_not_emit_security_event() {
let (mut alice, bob, group_id) = setup_alice_bob_charlie_group("Admin Remove");
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.set_member_role(&gid, &id("bob"), GroupRole::Admin)
.unwrap();
}
let events = collect_events(&mut alice);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("admin-remove-1", &id("bob"), &frame);
assert!(
unauthorized_change(&events).is_none(),
"an admin's commit must not be reported as unauthorized"
);
let removed_event = events.lock().unwrap().iter().find_map(|e| match e {
Event::GroupMemberRemoved {
user_id,
authorized,
..
} => Some((user_id.clone(), *authorized)),
_ => None,
});
assert_eq!(removed_event, Some((id("charlie"), Some(true))));
}
#[test]
fn test_admin_commit_with_mismatched_claim_reports_affected_member_mismatch() {
let (mut alice, bob, group_id) = setup_alice_bob_charlie_group("Mismatched Claim");
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls
.set_member_role(&gid, &id("bob"), GroupRole::Admin)
.unwrap();
}
let events = collect_events(&mut alice);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("dave")));
alice.handle_group_mls_commit("mismatched-claim-1", &id("bob"), &frame);
let (committer, added, removed, reason) = unauthorized_change(&events)
.expect("an admin commit whose framing names the wrong member must surface the event");
assert_eq!(committer, id("bob"));
assert!(added.is_empty(), "a Remove commit adds nobody");
assert_eq!(
removed,
vec![id("charlie")],
"the event must carry the actual MLS delta, not the claimed member"
);
assert_eq!(reason, "affected_member_mismatch");
let removed_event = events.lock().unwrap().iter().find_map(|e| match e {
Event::GroupMemberRemoved {
user_id,
authorized,
..
} => Some((user_id.clone(), *authorized)),
_ => None,
});
assert_eq!(removed_event, Some((id("charlie"), Some(false))));
}
#[test]
fn test_unauthorized_commit_still_applies_membership_and_does_not_fork() {
let (mut alice, bob, group_id) = setup_alice_bob_charlie_group("No Fork");
let epoch_before = group_epoch_of(&alice, &group_id);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("no-fork-1", &id("bob"), &frame);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before + 1,
"the commit must be merged so we stay on the same MLS branch as every other member"
);
let members = alice.group_mesh.members.get(&group_id).unwrap();
assert!(
!members.contains(&id("charlie")),
"the membership change is real and must be reflected in the local roster"
);
assert!(
members.contains(&id("alice")) && members.contains(&id("bob")),
"no other member should be affected"
);
}
#[test]
fn test_keyupdate_commit_emits_no_membership_or_security_events() {
let (mut alice, bob, group_id) = setup_alice_bob_charlie_group("KeyUpdate Quiet");
let events = collect_events(&mut alice);
let epoch_before = group_epoch_of(&alice, &group_id);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.update_keys(&gid).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "keyupdate", None);
alice.handle_group_mls_commit("keyupdate-1", &id("bob"), &frame);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before + 1,
"the KeyUpdate must merge even though bob is not an admin"
);
let events = events.lock().unwrap();
assert!(
!events.iter().any(|e| matches!(
e,
Event::GroupMemberAdded { .. }
| Event::GroupMemberRemoved { .. }
| Event::GroupUnauthorizedMembershipChange { .. }
)),
"a no-delta commit must emit no membership or security events"
);
}
#[test]
fn test_unauthorized_report_is_rate_limited_per_group_and_committer() {
let (mut alice, bob, group_id) = setup_alice_bob_charlie_group("Report Rate Limit");
let storage_d = Arc::new(crate::mls::InMemoryStorage::default());
let mut dave = OfflineProtocol::new(create_test_config_for_user("dave")).unwrap();
dave.initialize_mls_for_test(storage_d).unwrap();
let dave_kp = {
let dave_mls = dave.mls_manager_for_testing().read().unwrap();
dave_mls.generate_key_package().unwrap()
};
let events = collect_events(&mut alice);
let remove_commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&remove_commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("rate-limit-remove-1", &id("bob"), &frame);
let add_commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (_welcome, commit) = bob_mls
.add_group_member(&gid, &id("dave"), &dave_kp.key_package_data)
.unwrap();
commit
};
let frame = group_commit_frame(&add_commit, &group_id, "add", Some(&id("dave")));
alice.handle_group_mls_commit("rate-limit-add-1", &id("bob"), &frame);
let events = events.lock().unwrap();
let reports = events
.iter()
.filter(|e| matches!(e, Event::GroupUnauthorizedMembershipChange { .. }))
.count();
assert_eq!(
reports, 1,
"a repeat by the same (group, committer) within the window must not re-emit the report"
);
let removed_flag = events.iter().find_map(|e| match e {
Event::GroupMemberRemoved {
user_id,
authorized,
..
} if user_id == &id("charlie") => Some(*authorized),
_ => None,
});
assert_eq!(removed_flag, Some(Some(false)));
let added_flag = events.iter().find_map(|e| match e {
Event::GroupMemberAdded {
user_id,
authorized,
..
} if user_id == &id("dave") => Some(*authorized),
_ => None,
});
assert_eq!(added_flag, Some(Some(false)));
}
#[test]
fn test_judgment_covers_combined_add_and_remove_delta() {
let (mut alice, _bob, group_id) = setup_alice_bob_charlie_group("Combined Delta");
let events = collect_events(&mut alice);
let judgment =
alice.judge_membership_change(&group_id, &id("bob"), &[id("dave")], &[id("charlie")], true);
assert!(!judgment.sender_is_admin);
assert!(!judgment.authorized);
let (committer, added, removed, reason) = unauthorized_change(&events)
.expect("a combined add+remove delta must surface a single report");
assert_eq!(committer, id("bob"));
assert_eq!(added, vec![id("dave")]);
assert_eq!(removed, vec![id("charlie")]);
assert_eq!(reason, "sender_not_admin");
}
struct FailingMetadataStorage {
inner: crate::mls::InMemoryStorage,
fail_metadata_loads: std::sync::atomic::AtomicBool,
}
impl FailingMetadataStorage {
fn new() -> Self {
Self {
inner: crate::mls::InMemoryStorage::default(),
fail_metadata_loads: std::sync::atomic::AtomicBool::new(false),
}
}
fn set_failing(&self, failing: bool) {
self.fail_metadata_loads
.store(failing, std::sync::atomic::Ordering::SeqCst);
}
}
impl crate::mls::MlsStorage for FailingMetadataStorage {
fn store(
&self,
key_type: &str,
key_id: &str,
data: &[u8],
) -> offline_protocol_mls::storage::StorageResult<()> {
self.inner.store(key_type, key_id, data)
}
fn load(
&self,
key_type: &str,
key_id: &str,
) -> offline_protocol_mls::storage::StorageResult<Option<Vec<u8>>> {
if key_type == "group_metadata"
&& self
.fail_metadata_loads
.load(std::sync::atomic::Ordering::SeqCst)
{
return Err(crate::mls::StorageError::LoadFailed(
"injected metadata read failure".to_string(),
));
}
self.inner.load(key_type, key_id)
}
fn delete(
&self,
key_type: &str,
key_id: &str,
) -> offline_protocol_mls::storage::StorageResult<()> {
self.inner.delete(key_type, key_id)
}
fn list_keys(
&self,
key_type: &str,
) -> offline_protocol_mls::storage::StorageResult<Vec<String>> {
self.inner.list_keys(key_type)
}
}
#[test]
fn test_failed_roster_read_skips_delta_and_judgment_but_merges_commit() {
let storage_a = Arc::new(FailingMetadataStorage::new());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a.clone()).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group("Roster Read Failure").unwrap();
let group_id = group_info.group_id.as_str().to_string();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let (welcome, _commit) = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap()
};
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.join_group(&welcome).unwrap();
}
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut charlie = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
charlie.initialize_mls_for_test(storage_c).unwrap();
let charlie_kp = {
let charlie_mls = charlie.mls_manager_for_testing().read().unwrap();
charlie_mls.generate_key_package().unwrap()
};
let add_commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let (_welcome, commit) = alice_mls
.add_group_member(&gid, &id("charlie"), &charlie_kp.key_package_data)
.unwrap();
commit
};
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls
.decrypt_from_group(&add_commit, &id("alice"))
.unwrap();
}
alice.refresh_group_members(&group_id).unwrap();
let events = collect_events(&mut alice);
let epoch_before = group_epoch_of(&alice, &group_id);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("charlie")));
storage_a.set_failing(true);
alice.handle_group_mls_commit("storage-fail-1", &id("bob"), &frame);
storage_a.set_failing(false);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before + 1,
"the commit must still merge — a local read failure must not fork us from the group"
);
{
let events = events.lock().unwrap();
assert!(
!events.iter().any(|e| matches!(
e,
Event::GroupMemberAdded { .. }
| Event::GroupMemberRemoved { .. }
| Event::GroupUnauthorizedMembershipChange { .. }
)),
"an unknowable delta must be skipped, not fabricated from an empty default"
);
}
let members = alice.refresh_group_members(&group_id).unwrap();
assert!(
!members.iter().any(|m| m == &id("charlie")),
"the roster must converge to MLS state once reads succeed again"
);
}
fn group_welcome_frame(
welcome: &offline_protocol_mls::WelcomeMessage,
group_id: &str,
member_list: &[&str],
member_roles: serde_json::Value,
created_by: Option<&str>,
) -> String {
let mut body = serde_json::json!({
"group_id": group_id,
"group_name": "Creator Group",
"welcome_data": base64_encode(&welcome.welcome_data),
"member_list": member_list,
"member_roles": member_roles,
});
if let Some(creator) = created_by {
body["created_by"] = serde_json::json!(creator);
}
body.to_string()
}
fn stage_welcome_for_bob(
group_name: &str,
) -> (
OfflineProtocol,
OfflineProtocol,
String,
offline_protocol_mls::WelcomeMessage,
) {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(create_test_config_for_user("alice")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group(group_name).unwrap();
let group_id = group_info.group_id.as_str().to_string();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let welcome = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (welcome, _commit) = alice_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap();
welcome
};
alice.refresh_group_members(&group_id).unwrap();
(alice, bob, group_id, welcome)
}
fn creator_of(protocol: &OfflineProtocol, group_id: &str) -> Option<String> {
let mls = protocol.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(group_id).unwrap();
mls.get_group_metadata(&gid)
.unwrap()
.and_then(|m| m.created_by)
}
#[test]
fn test_welcome_propagates_group_creator_to_joiner() {
let (_alice, mut bob, group_id, welcome) = stage_welcome_for_bob("Creator Group");
assert_eq!(
creator_of(&bob, &group_id),
None,
"precondition: Bob has no metadata for a group he has not joined"
);
let frame = group_welcome_frame(
&welcome,
&group_id,
&["alice", "bob"],
serde_json::json!({"alice": "admin", "bob": "member"}),
Some("alice"),
);
bob.handle_group_mls_welcome("welcome-creator-1", "alice", &frame);
assert_eq!(
creator_of(&bob, &group_id),
Some("alice".to_string()),
"the joiner must adopt the creator the Welcome carried"
);
}
#[test]
fn test_joiner_without_role_snapshot_resolves_admin_via_created_by() {
let (_alice, mut bob, group_id, welcome) = stage_welcome_for_bob("Creator Group");
let frame = group_welcome_frame(
&welcome,
&group_id,
&["alice", "bob"],
serde_json::json!({}),
Some("alice"),
);
bob.handle_group_mls_welcome("welcome-creator-2", "alice", &frame);
assert!(
bob.check_is_admin(&group_id, "alice").unwrap(),
"with no roles stored, the creator fallback must resolve the creator as admin"
);
assert!(
!bob.check_is_admin(&group_id, "bob").unwrap(),
"the fallback must not promote anyone but the creator"
);
}
#[test]
fn test_welcome_created_by_does_not_overwrite_existing() {
let (alice, _bob, group_id, _welcome) = stage_welcome_for_bob("Creator Group");
assert_eq!(
creator_of(&alice, &group_id),
Some(id("alice")),
"precondition: the creator has itself on record"
);
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
alice_mls.set_group_creator(&gid, &id("mallory")).unwrap();
}
assert_eq!(
creator_of(&alice, &group_id),
Some(id("alice")),
"an established creator must never be rewritten"
);
}
#[test]
fn test_welcome_without_created_by_leaves_metadata_unchanged() {
let (_alice, mut bob, group_id, welcome) = stage_welcome_for_bob("Creator Group");
let frame = group_welcome_frame(
&welcome,
&group_id,
&[&id("alice"), &id("bob")],
serde_json::json!({&id("alice"): "admin"}),
None,
);
bob.handle_group_mls_welcome("welcome-creator-3", &id("alice"), &frame);
assert!(
bob.group_mesh.members.contains_key(&group_id),
"a Welcome without the additive field must still join"
);
assert_eq!(
creator_of(&bob, &group_id),
None,
"absence means no information — never a fabricated creator"
);
assert!(
bob.check_is_admin(&group_id, &id("alice")).unwrap(),
"the role snapshot still resolves admin without the fallback"
);
}
fn setup_enforcing_group(
group_name: &str,
alice_enforces: bool,
bob_enforces: bool,
) -> (OfflineProtocol, OfflineProtocol, String) {
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice_config = create_test_config_for_user("alice");
alice_config.group.enforce_admin_commits = alice_enforces;
let mut bob_config = create_test_config_for_user("bob");
bob_config.group.enforce_admin_commits = bob_enforces;
let mut alice = OfflineProtocol::new(alice_config).unwrap();
let mut bob = OfflineProtocol::new(bob_config).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
alice.start().unwrap();
bob.start().unwrap();
let group_info = alice.create_group(group_name).unwrap();
let group_id = group_info.group_id.as_str().to_string();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let welcome = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let (welcome, _commit) = alice_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap();
welcome
};
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.join_group(&welcome).unwrap();
}
let storage_c = Arc::new(crate::mls::InMemoryStorage::new());
let mut charlie = OfflineProtocol::new(create_test_config_for_user("charlie")).unwrap();
charlie.initialize_mls_for_test(storage_c).unwrap();
let charlie_kp = {
let charlie_mls = charlie.mls_manager_for_testing().read().unwrap();
charlie_mls.generate_key_package().unwrap()
};
let commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let (_welcome, commit) = alice_mls
.add_group_member(&gid, &id("charlie"), &charlie_kp.key_package_data)
.unwrap();
commit
};
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.decrypt_from_group(&commit, &id("alice")).unwrap();
}
alice.refresh_group_members(&group_id).unwrap();
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls
.set_member_role(&gid, &id("bob"), GroupRole::Member)
.unwrap();
alice_mls
.set_member_role(&gid, &id("charlie"), GroupRole::Member)
.unwrap();
}
(alice, bob, group_id)
}
fn unauthorized_change_enforced(
events: &Arc<Mutex<Vec<Event>>>,
) -> Option<(String, Vec<String>, Vec<String>, String, bool)> {
events.lock().unwrap().iter().find_map(|e| match e {
Event::GroupUnauthorizedMembershipChange {
committer,
added,
removed,
reason,
enforced,
..
} => Some((
committer.clone(),
added.clone(),
removed.clone(),
reason.clone(),
*enforced,
)),
_ => None,
})
}
#[test]
fn test_enforcement_disabled_by_default_applies_commit() {
let (mut alice, bob, group_id) = setup_enforcing_group("Default Off", false, false);
assert!(
!alice.config.group.enforce_admin_commits,
"enforcement must be off by default"
);
let events = collect_events(&mut alice);
let epoch_before = group_epoch_of(&alice, &group_id);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("default-off-1", &id("bob"), &frame);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before + 1,
"with enforcement off the commit must still merge"
);
let (_, _, _, _, enforced) =
unauthorized_change_enforced(&events).expect("the change must still be reported");
assert!(!enforced, "an applied change must report enforced = false");
}
#[test]
fn test_enforced_non_admin_remove_commit_is_rejected_without_merge() {
let (mut alice, bob, group_id) = setup_enforcing_group("Enforced Remove", true, false);
let events = collect_events(&mut alice);
let epoch_before = group_epoch_of(&alice, &group_id);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("enforced-remove-1", &id("bob"), &frame);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before,
"a refused commit must not advance the local epoch"
);
let members = alice.refresh_group_members(&group_id).unwrap();
assert!(
members.iter().any(|m| m == &id("charlie")),
"the refused removal must not have changed the roster"
);
let (committer, added, removed, reason, enforced) =
unauthorized_change_enforced(&events).expect("a refused commit must be reported");
assert_eq!(committer, id("bob"));
assert!(added.is_empty());
assert_eq!(removed, vec![id("charlie")]);
assert_eq!(reason, "sender_not_admin");
assert!(enforced, "a refused change must report enforced = true");
assert!(
!events.lock().unwrap().iter().any(|e| matches!(
e,
Event::GroupMemberAdded { .. } | Event::GroupMemberRemoved { .. }
)),
"a refused commit must emit no roster event"
);
assert!(
!alice.group_mesh.pending_commits.contains_key(&group_id),
"a refused commit must not be buffered and retried"
);
}
#[test]
fn test_enforced_non_admin_add_commit_is_rejected_without_merge() {
let (mut alice, bob, group_id) = setup_enforcing_group("Enforced Add", true, false);
let events = collect_events(&mut alice);
let epoch_before = group_epoch_of(&alice, &group_id);
let storage_d = Arc::new(crate::mls::InMemoryStorage::default());
let mut dave = OfflineProtocol::new(create_test_config_for_user("dave")).unwrap();
dave.initialize_mls_for_test(storage_d).unwrap();
let dave_kp = {
let dave_mls = dave.mls_manager_for_testing().read().unwrap();
dave_mls.generate_key_package().unwrap()
};
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let (_welcome, commit) = bob_mls
.add_group_member(&gid, &id("dave"), &dave_kp.key_package_data)
.unwrap();
commit
};
let frame = group_commit_frame(&commit, &group_id, "add", Some(&id("dave")));
alice.handle_group_mls_commit("enforced-add-1", &id("bob"), &frame);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before,
"a refused Add must not advance the local epoch"
);
let members = alice.refresh_group_members(&group_id).unwrap();
assert!(
!members.iter().any(|m| m == &id("dave")),
"the refused Add must not have spliced dave into the roster"
);
let (committer, added, _, _, enforced) =
unauthorized_change_enforced(&events).expect("a refused Add must be reported");
assert_eq!(committer, id("bob"));
assert_eq!(added, vec![id("dave")]);
assert!(enforced);
}
#[test]
fn test_enforced_admin_remove_commit_is_applied() {
let (alice, mut bob, group_id) = setup_enforcing_group("Enforced Admin OK", false, true);
assert!(
bob.config.group.enforce_admin_commits,
"precondition: the receiving peer must actually be enforcing, or this \
test passes for the wrong reason"
);
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
bob_mls
.set_member_role(&gid, &id("charlie"), GroupRole::Member)
.unwrap();
}
let events = collect_events(&mut bob);
let epoch_before = group_epoch_of(&bob, &group_id);
let commit = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("charlie")));
bob.handle_group_mls_commit("enforced-admin-1", &id("alice"), &frame);
assert_eq!(
group_epoch_of(&bob, &group_id),
epoch_before + 1,
"an admin's commit must merge under enforcement"
);
assert!(
unauthorized_change_enforced(&events).is_none(),
"an authorized commit must not be reported"
);
}
#[test]
fn test_enforcement_fails_open_when_admin_set_unknown() {
let (mut alice, bob, group_id) = setup_enforcing_group("Fail Open", true, false);
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls
.set_member_role(&gid, &id("alice"), GroupRole::Member)
.unwrap();
let metadata = alice_mls.get_group_metadata(&gid).unwrap().unwrap();
assert!(
!metadata.has_any_admin(),
"precondition: no admin role is stored"
);
assert!(
metadata.created_by.is_some(),
"precondition: a creator IS on record — enforcement must still fail \
open, because one unauthenticated claim is too thin to fork over"
);
}
let epoch_before = group_epoch_of(&alice, &group_id);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("fail-open-1", &id("bob"), &frame);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before + 1,
"an unknown admin set must fail OPEN — rejecting here forks the group"
);
}
#[test]
fn test_enforced_keyupdate_commit_applies() {
let (mut alice, bob, group_id) = setup_enforcing_group("Enforced KeyUpdate", true, false);
let events = collect_events(&mut alice);
let epoch_before = group_epoch_of(&alice, &group_id);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.update_keys(&gid).unwrap()
};
let frame = group_commit_frame(&commit, &group_id, "keyupdate", None);
alice.handle_group_mls_commit("enforced-keyupdate-1", &id("bob"), &frame);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before + 1,
"a KeyUpdate carries no membership change and must merge from any member"
);
assert!(
unauthorized_change_enforced(&events).is_none(),
"a KeyUpdate must not be reported as an unauthorized membership change"
);
}
#[test]
fn test_app_channel_commit_is_also_policy_gated() {
let (mut alice, bob, group_id) = setup_enforcing_group("App Channel Bypass", true, false);
let events = collect_events(&mut alice);
let epoch_before = group_epoch_of(&alice, &group_id);
let commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let reframed = serde_json::json!({
"group_id": group_id,
"ciphertext": base64_encode(&commit.ciphertext),
"epoch": commit.epoch,
})
.to_string();
let wire = make_message(&id("bob"), &id("alice"), "unused-envelope");
let result = alice.handle_group_mls_msg(&wire, &id("bob"), &reframed);
assert_eq!(
group_epoch_of(&alice, &group_id),
epoch_before,
"a commit reframed onto the application channel must be refused too"
);
assert!(
matches!(result, InternalMessageResult::Consumed),
"the refusal is permanent, so the frame must be consumed, not deferred"
);
let (committer, _, removed, _, enforced) = unauthorized_change_enforced(&events)
.expect("a reframed commit must be reported like the commit-channel one");
assert_eq!(committer, id("bob"));
assert_eq!(removed, vec![id("charlie")]);
assert!(enforced);
}
#[test]
fn test_session_paths_unaffected_by_enforcement() {
let mut config_a = create_test_config_for_user("alice");
config_a.group.enforce_admin_commits = true;
let storage_a = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut alice = OfflineProtocol::new(config_a).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
alice.initialize_mls_for_test(storage_a).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
let alice_kp = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls.generate_key_package().unwrap()
};
let welcome = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls
.import_key_package(&id("alice"), &alice_kp.key_package_data)
.unwrap();
bob_mls.create_session(&id("alice")).unwrap()
};
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls.join_session(&welcome).unwrap();
}
let encrypted = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls
.encrypt_for_user(&id("alice"), b"hello session")
.unwrap()
};
let plaintext = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls.decrypt(&encrypted, &id("bob")).unwrap()
};
assert_eq!(
plaintext,
Some(b"hello session".to_vec()),
"1:1 sessions must be unaffected by group commit enforcement"
);
}
#[test]
fn test_enforced_alarm_is_not_suppressed_by_an_earlier_applied_report() {
let (mut alice, bob, group_id) = setup_enforcing_group("Alarm Not Suppressed", true, false);
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls
.set_member_role(&gid, &id("alice"), GroupRole::Member)
.unwrap();
}
let events = collect_events(&mut alice);
let remove_commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&remove_commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("alarm-applied-1", &id("bob"), &frame);
{
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
alice_mls
.set_member_role(&gid, &id("alice"), GroupRole::Admin)
.unwrap();
alice_mls
.set_member_role(&gid, &id("bob"), GroupRole::Member)
.unwrap();
}
let storage_d = Arc::new(crate::mls::InMemoryStorage::default());
let mut dave = OfflineProtocol::new(create_test_config_for_user("dave")).unwrap();
dave.initialize_mls_for_test(storage_d).unwrap();
let dave_kp = {
let dave_mls = dave.mls_manager_for_testing().read().unwrap();
dave_mls.generate_key_package().unwrap()
};
let add_commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
let (_welcome, commit) = bob_mls
.add_group_member(&gid, &id("dave"), &dave_kp.key_package_data)
.unwrap();
commit
};
let frame = group_commit_frame(&add_commit, &group_id, "add", Some(&id("dave")));
alice.handle_group_mls_commit("alarm-enforced-1", &id("bob"), &frame);
let flags: Vec<bool> = events
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
Event::GroupUnauthorizedMembershipChange { enforced, .. } => Some(*enforced),
_ => None,
})
.collect();
assert_eq!(
flags,
vec![false, true],
"the refusal alarm must survive the applied report's rate-limit window"
);
assert!(
!events.lock().unwrap().iter().any(
|e| matches!(e, Event::GroupMemberAdded { user_id, .. } if user_id == &id("dave"))
),
"the refused Add must not have spliced dave into the roster"
);
}
#[test]
fn test_repeated_refusals_by_one_committer_are_still_rate_limited() {
let (mut alice, bob, group_id) = setup_enforcing_group("Refusal Rate Limit", true, false);
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let events = collect_events(&mut alice);
let remove_commit = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.remove_group_member(&gid, &id("charlie")).unwrap()
};
let frame = group_commit_frame(&remove_commit, &group_id, "remove", Some(&id("charlie")));
alice.handle_group_mls_commit("refusal-1", &id("bob"), &frame);
alice.handle_group_mls_commit("refusal-2", &id("bob"), &frame);
let reports = events
.lock()
.unwrap()
.iter()
.filter(|e| matches!(e, Event::GroupUnauthorizedMembershipChange { .. }))
.count();
assert_eq!(
reports, 1,
"repeat refusals by the same (group, committer) must stay suppressed within the window"
);
}
#[test]
fn test_group_invite_with_an_unprovable_member_is_declined_and_reported() {
let storage_m = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut mallory = OfflineProtocol::new(create_test_config_for_user("mallory")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
mallory.initialize_mls_for_test(storage_m).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
mallory.start().unwrap();
bob.start().unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let collector = events.clone();
bob.on_event(move |event| collector.lock().unwrap().push(event));
let group_info = mallory.create_group("mallory-group").unwrap();
let gid = offline_protocol_mls::GroupId::new(group_info.group_id.as_str()).unwrap();
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let welcome = {
let mallory_mls = mallory.mls_manager_for_testing().read().unwrap();
mallory_mls
.seat_forged_leaf_for_testing(&gid, &id("carol"))
.unwrap();
mallory_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap()
.0
};
let payload = GroupMlsWelcomePayload {
member_rich: HashMap::new(),
member_data: HashMap::new(),
created_by: None,
group_id: group_info.group_id.to_string(),
group_name: Some("mallory-group".to_string()),
welcome_data: base64_encode(&welcome.welcome_data),
member_list: vec![id("mallory"), id("carol"), id("bob")],
member_roles: HashMap::new(),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&payload).unwrap()
);
bob.process_internal_message(&make_message(&id("mallory"), &id("bob"), &content));
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
assert!(
bob_mls.get_group_info(&gid).unwrap().is_none(),
"a Welcome carrying an unprovable member must not install the group"
);
}
let events = events.lock().unwrap();
let reported = events.iter().find_map(|event| match event {
Event::SecurityWarning {
peer_id,
reason_code,
..
} if *reason_code == crate::events::SecurityWarningCode::GroupLeafIdentityUnproven => {
Some(peer_id.clone())
}
_ => None,
});
assert_eq!(
reported.as_deref(),
Some(id("mallory").as_str()),
"expected the declined invite to be reported against its sender; events: {:?}",
events
.iter()
.map(|e| format!("{:?}", e))
.collect::<Vec<_>>()
);
}
#[test]
fn test_forged_leaf_commit_is_rejected_permanently_and_not_buffered() {
let (alice, mut bob, group_id) = setup_alice_bob_group("Forged Leaf Commit Group");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let collector = events.clone();
bob.on_event(move |event| collector.lock().unwrap().push(event));
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
let epoch_before = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.get_group_info(&gid).unwrap().unwrap().epoch
};
let (commit_bytes, alice_epoch) = {
let alice_mls = alice.mls_manager_for_testing().read().unwrap();
let bytes = alice_mls
.seat_forged_leaf_for_testing(&gid, &id("carol"))
.unwrap();
let epoch = alice_mls.get_group_info(&gid).unwrap().unwrap().epoch;
(bytes, epoch)
};
let commit_payload = GroupMlsCommitPayload {
affected_member_rich: None,
affected_member_data: None,
group_id: group_id.clone(),
commit_type: GroupCommitType::Add,
ciphertext: base64_encode(&commit_bytes),
epoch: alice_epoch,
affected_member: Some(id("carol")),
role: None,
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_COMMIT,
serde_json::to_string(&commit_payload).unwrap()
);
bob.process_internal_message(&make_message(&id("alice"), &id("bob"), &content));
assert!(
!bob.group_mesh.pending_commits.contains_key(&group_id),
"a forged-leaf commit can never succeed and must not enter the retry buffer"
);
assert!(
!bob.group_mesh.epoch_forks.contains_key(&group_id),
"refusing a forged-leaf commit must not be mistaken for an epoch fork"
);
let after = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.get_group_info(&gid).unwrap().unwrap()
};
assert_eq!(
after.epoch, epoch_before,
"a refused commit must not advance the epoch"
);
assert!(
!after.members.contains(&id("carol")),
"roster: {:?}",
after.members
);
let unproven = |events: &Vec<Event>| -> Vec<String> {
events
.iter()
.filter_map(|event| match event {
Event::SecurityWarning {
peer_id,
reason_code,
..
} if *reason_code
== crate::events::SecurityWarningCode::GroupLeafIdentityUnproven =>
{
Some(peer_id.clone())
}
_ => None,
})
.collect()
};
{
let events = events.lock().unwrap();
assert_eq!(
unproven(&events),
vec![id("alice")],
"a refused membership change must be reported once, against its sender; events: {:?}",
events
.iter()
.map(|e| format!("{:?}", e))
.collect::<Vec<_>>()
);
}
}
#[test]
fn test_repeated_unprovable_invites_from_one_sender_are_rate_limited() {
let storage_m = Arc::new(crate::mls::InMemoryStorage::default());
let storage_b = Arc::new(crate::mls::InMemoryStorage::new());
let mut mallory = OfflineProtocol::new(create_test_config_for_user("mallory")).unwrap();
let mut bob = OfflineProtocol::new(create_test_config_for_user("bob")).unwrap();
mallory.initialize_mls_for_test(storage_m).unwrap();
bob.initialize_mls_for_test(storage_b).unwrap();
mallory.start().unwrap();
bob.start().unwrap();
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let collector = events.clone();
bob.on_event(move |event| collector.lock().unwrap().push(event));
let group_info = mallory.create_group("mallory-group").unwrap();
let gid = offline_protocol_mls::GroupId::new(group_info.group_id.as_str()).unwrap();
{
let mallory_mls = mallory.mls_manager_for_testing().read().unwrap();
mallory_mls
.seat_forged_leaf_for_testing(&gid, &id("carol"))
.unwrap();
}
let invite = |bob: &mut OfflineProtocol, mallory: &OfflineProtocol, remove_first: bool| {
let bob_kp = {
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls.generate_key_package().unwrap()
};
let welcome = {
let mallory_mls = mallory.mls_manager_for_testing().read().unwrap();
if remove_first {
mallory_mls.remove_group_member(&gid, &id("bob")).unwrap();
}
mallory_mls
.add_group_member(&gid, &id("bob"), &bob_kp.key_package_data)
.unwrap()
.0
};
let payload = GroupMlsWelcomePayload {
member_rich: HashMap::new(),
member_data: HashMap::new(),
created_by: None,
group_id: group_info.group_id.to_string(),
group_name: Some("mallory-group".to_string()),
welcome_data: base64_encode(&welcome.welcome_data),
member_list: vec![id("mallory"), id("carol"), id("bob")],
member_roles: HashMap::new(),
};
let content = format!(
"{}{}",
internal_prefixes::GROUP_MLS_WELCOME,
serde_json::to_string(&payload).unwrap()
);
bob.process_internal_message(&make_message(&id("mallory"), &id("bob"), &content));
};
invite(&mut bob, &mallory, false);
let after_first = events
.lock()
.unwrap()
.iter()
.filter(|e| {
matches!(e, Event::SecurityWarning { reason_code, .. }
if *reason_code == crate::events::SecurityWarningCode::GroupLeafIdentityUnproven)
})
.count();
assert_eq!(after_first, 1, "the first refusal must be reported");
invite(&mut bob, &mallory, true);
let events = events.lock().unwrap();
let total = events
.iter()
.filter(|e| {
matches!(e, Event::SecurityWarning { reason_code, .. }
if *reason_code == crate::events::SecurityWarningCode::GroupLeafIdentityUnproven)
})
.count();
assert_eq!(
total,
1,
"a second refusal for the same (group, sender) must stay suppressed inside the window; \
events: {:?}",
events
.iter()
.map(|e| format!("{:?}", e))
.collect::<Vec<_>>()
);
let reasons: Vec<String> = events
.iter()
.filter_map(|e| match e {
Event::SecurityWarning {
reason_code,
reason,
..
} if *reason_code == crate::events::SecurityWarningCode::GroupLeafIdentityUnproven => {
Some(reason.clone())
}
_ => None,
})
.collect();
for reason in &reasons {
assert!(
!reason.contains("off1"),
"the warning reason must carry no address; got {reason:?}"
);
}
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
assert!(
bob_mls.get_group_info(&gid).unwrap().is_none(),
"neither invite may install the group"
);
}
}
#[test]
fn test_leaf_seated_in_local_state_is_reported_on_the_roster_read() {
let (_alice, mut bob, group_id) = setup_alice_bob_group("Seated Leaf Group");
let events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
let collector = events.clone();
bob.on_event(move |event| collector.lock().unwrap().push(event));
let gid = offline_protocol_mls::GroupId::new(&group_id).unwrap();
bob.refresh_group_members(&group_id).unwrap();
let unproven = |events: &Arc<Mutex<Vec<Event>>>| -> Vec<String> {
events
.lock()
.unwrap()
.iter()
.filter_map(|event| match event {
Event::SecurityWarning {
peer_id,
reason_code,
..
} if *reason_code
== crate::events::SecurityWarningCode::GroupLeafIdentityUnproven =>
{
Some(peer_id.clone())
}
_ => None,
})
.collect()
};
assert!(
unproven(&events).is_empty(),
"an honest group must report nothing"
);
{
let bob_mls = bob.mls_manager_for_testing().read().unwrap();
bob_mls
.seat_forged_leaf_for_testing(&gid, &id("carol"))
.unwrap();
}
let members = bob.refresh_group_members(&group_id).unwrap();
assert!(
!members.contains(&id("carol")),
"the roster must still exclude the unproven leaf: {members:?}"
);
assert_eq!(
unproven(&events),
vec![bob.local_id.clone()],
"a seated unprovable leaf must be reported once, against this device"
);
bob.refresh_group_members(&group_id).unwrap();
bob.refresh_group_members(&group_id).unwrap();
assert_eq!(
unproven(&events).len(),
1,
"a persistent finding must not re-report on every roster read"
);
for event in events.lock().unwrap().iter() {
if let Event::SecurityWarning {
reason_code,
reason,
..
} = event
{
if *reason_code == crate::events::SecurityWarningCode::GroupLeafIdentityUnproven {
assert!(
!reason.contains("off1"),
"the warning reason must carry no address; got {reason:?}"
);
}
}
}
}