osmaczko 639632b775
feat: surface pending group invites on the roster (#188)
A GroupV2 add is staged as a proposal that a later commit merges, so between the two the invited member sits in the conversation's own state and nowhere in its public roster, leaving a caller no way to observe an invite in flight.

group_members now returns those invites after the committed members, flagged pending. The flag is inviter-local and transient: a conversation records only the joiners this client proposed, and drops each one as the commit admitting it lands. GroupV1 never reports a pending member, since its add merges its own commit before returning.
2026-07-24 19:09:44 +02:00

478 lines
18 KiB
Rust

//! GroupV2 through the threaded client: three accounts on the in-process
//! transport, driven purely over the public `ChatClient` API and its event
//! channel. Group commits and welcomes are minted asynchronously by de-mls
//! (wakeup-driven), so assertions wait for events rather than expecting a
//! fixed sequence.
use std::time::Duration;
use components::EphemeralRegistry;
use crossbeam_channel::Receiver;
use libchat::ChatStorage;
use logos_account::TestLogosAccount;
use logos_generic_chat::{
ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner, Event, GroupMetadata,
GroupV2Config, InProcessDelivery, MessageBus,
};
/// Metadata for a group these tests create without a name or description.
fn unnamed_group() -> GroupMetadata {
GroupMetadata::new("", "")
}
/// Millisecond GroupV2 timers so the de-mls commit/consensus dance completes
/// in test time; the library defaults wait 60s before committing an add.
fn fast_group_v2_config() -> GroupV2Config {
GroupV2Config {
commit_inactivity_duration: Duration::from_millis(50),
freeze_duration: Duration::from_millis(20),
voting_delay: Duration::from_millis(30),
election_voting_delay: Duration::from_millis(30),
consensus_timeout: Duration::from_millis(150),
proposal_expiration: Duration::from_millis(2000),
..GroupV2Config::default()
}
}
type TestClient = ChatClient<InProcessDelivery, EphemeralRegistry, ChatStorage>;
/// A client for a fresh account: mints the account and a delegate, publishes
/// the endorsing bundle, and builds the client on the shared bus/registry with
/// the fast GroupV2 timers. Returns the account address peers invite by.
fn create_test_client(
message_bus: MessageBus,
reg: EphemeralRegistry,
) -> (TestClient, Receiver<Event>, String) {
create_test_client_with(message_bus, reg, fast_group_v2_config())
}
/// [`create_test_client`] with explicit GroupV2 timers, for a test that needs
/// to observe the group between two protocol steps.
fn create_test_client_with(
message_bus: MessageBus,
mut reg: EphemeralRegistry,
config: GroupV2Config,
) -> (TestClient, Receiver<Event>, String) {
let account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
account
.add_delegate_signer(&mut reg, delegate.public_key())
.unwrap();
let (client, events) = ChatClientBuilder::new(account.address())
.ident(delegate)
.transport(InProcessDelivery::new(message_bus))
.registration(reg)
.group_v2_config(config)
.build()
.expect("client create");
let addr = client.addr().to_string();
(client, events, addr)
}
/// Wait until an event matching `f` arrives, skipping unrelated events (group
/// protocol traffic can interleave observations); panic after `timeout`.
fn wait_for_event<F, T>(events: &Receiver<Event>, label: &str, timeout: Duration, mut f: F) -> T
where
F: FnMut(&Event) -> Option<T>,
{
let deadline = std::time::Instant::now() + timeout;
loop {
let remaining = deadline
.checked_duration_since(std::time::Instant::now())
.unwrap_or_else(|| panic!("timed out waiting for {label}"));
match events.recv_timeout(remaining) {
Ok(event) => {
if let Some(out) = f(&event) {
return out;
}
}
Err(_) => panic!("timed out waiting for {label}"),
}
}
}
/// Wait for the group conversation to start on a joiner and return its id.
fn wait_for_group_started(events: &Receiver<Event>, label: &str) -> String {
wait_for_event(events, label, Duration::from_secs(10), |e| match e {
Event::ConversationStarted { convo_id, class } => {
assert_eq!(*class, ConversationClass::Group);
Some(convo_id.to_string())
}
_ => None,
})
}
/// Poll a client's roster for `convo_id` until its verified *committed*
/// accounts equal `expected` (order-independent), or panic after a timeout. The
/// roster settles asynchronously as each member applies the add commit, so it is
/// polled rather than snapshotted; members still awaiting that commit are
/// skipped so an invite alone never reads as convergence.
fn wait_for_members(client: &mut TestClient, convo_id: &str, expected: &[&str]) {
use std::collections::BTreeSet;
let want: BTreeSet<&str> = expected.iter().copied().collect();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let roster = client.group_members(convo_id).expect("group_members");
let got: BTreeSet<&str> = roster
.iter()
.filter(|m| !m.pending)
.filter_map(|m| m.account.as_ref().map(|a| a.as_str()))
.collect();
if got == want {
return;
}
if std::time::Instant::now() >= deadline {
panic!("roster did not converge for {convo_id}: got {got:?}, want {want:?}");
}
std::thread::sleep(Duration::from_millis(20));
}
}
/// Wait for `content` to arrive and return the sender's verified account.
fn wait_for_message(events: &Receiver<Event>, content: &[u8]) -> Option<String> {
let label = format!("MessageReceived({})", String::from_utf8_lossy(content));
wait_for_event(events, &label, Duration::from_secs(10), |e| match e {
Event::MessageReceived {
content: got,
sender,
..
} if got == content => Some(sender.account.as_ref().map(|a| a.as_str().to_string())),
_ => None,
})
}
/// A three-account group: saro creates it with raya, raya (a non-creator)
/// adds pax, and a message from each member reaches both others with a
/// directory-verified sender account.
#[test]
fn group_v2_three_members() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, saro_events, saro_addr) = create_test_client(bus.clone(), reg.clone());
let (mut raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let (mut pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone());
let convo_id = saro
.create_group_conversation(&[&raya_addr], unnamed_group())
.expect("saro create group");
// The invite lands once saro's steward commit finalizes (wakeup-driven);
// both sides then share the de-mls conversation id.
let raya_convo_id = wait_for_group_started(&raya_events, "raya ConversationStarted");
assert_eq!(raya_convo_id, convo_id);
// Both sides see the two-account roster once the add commits.
wait_for_members(&mut saro, &convo_id, &[&saro_addr, &raya_addr]);
wait_for_members(&mut raya, &raya_convo_id, &[&saro_addr, &raya_addr]);
saro.send_message(&convo_id, b"hello raya").unwrap();
assert_eq!(
wait_for_message(&raya_events, b"hello raya").as_deref(),
Some(saro_addr.as_str())
);
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
assert_eq!(
wait_for_message(&saro_events, b"hi saro").as_deref(),
Some(raya_addr.as_str())
);
// A non-creator grows the group: raya proposes pax, the steward commits,
// and raya (who holds the pending invite) routes the welcome to pax.
raya.add_group_members(&raya_convo_id, &[&pax_addr])
.expect("raya add pax");
let pax_convo_id = wait_for_group_started(&pax_events, "pax ConversationStarted");
assert_eq!(pax_convo_id, convo_id);
// Everyone is at the post-add epoch: a message from the creator reaches
// both peers, and one from the newest member reaches both elders.
saro.send_message(&convo_id, b"all three?").unwrap();
assert_eq!(
wait_for_message(&raya_events, b"all three?").as_deref(),
Some(saro_addr.as_str())
);
assert_eq!(
wait_for_message(&pax_events, b"all three?").as_deref(),
Some(saro_addr.as_str())
);
pax.send_message(&pax_convo_id, b"pax is in").unwrap();
assert_eq!(
wait_for_message(&saro_events, b"pax is in").as_deref(),
Some(pax_addr.as_str())
);
assert_eq!(
wait_for_message(&raya_events, b"pax is in").as_deref(),
Some(pax_addr.as_str())
);
// All three rosters converge on the same three accounts.
let all = [saro_addr.as_str(), raya_addr.as_str(), pax_addr.as_str()];
wait_for_members(&mut saro, &convo_id, &all);
wait_for_members(&mut raya, &raya_convo_id, &all);
wait_for_members(&mut pax, &pax_convo_id, &all);
assert_eq!(saro.list_conversations().unwrap().len(), 1);
assert_eq!(raya.list_conversations().unwrap().len(), 1);
assert_eq!(pax.list_conversations().unwrap().len(), 1);
}
/// The same two peers are invited to several groups at once. Each installation
/// registers a single key package, so admitting it to more than one group only
/// works if that key package survives a join — a regression guard for the
/// multi-group "welcome not addressed to this member" flake.
#[test]
fn peers_invited_to_many_groups() {
const GROUPS: usize = 3;
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, saro_addr) = create_test_client(bus.clone(), reg.clone());
let (_raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let (_pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone());
// Saro opens several groups, each inviting both Raya and Pax; every group
// reuses Raya's and Pax's one key package.
let mut convo_ids = Vec::new();
for _ in 0..GROUPS {
convo_ids.push(
saro.create_group_conversation(&[&raya_addr, &pax_addr], unnamed_group())
.expect("saro create group"),
);
}
// Both peers must join all of them.
for _ in 0..GROUPS {
wait_for_group_started(&raya_events, "raya joins a group");
wait_for_group_started(&pax_events, "pax joins a group");
}
// Every group is live: a distinct message in each reaches both peers with
// the creator's verified account.
for (i, convo_id) in convo_ids.iter().enumerate() {
let msg = format!("hello group {i}").into_bytes();
saro.send_message(convo_id, &msg).unwrap();
assert_eq!(
wait_for_message(&raya_events, &msg).as_deref(),
Some(saro_addr.as_str())
);
assert_eq!(
wait_for_message(&pax_events, &msg).as_deref(),
Some(saro_addr.as_str())
);
}
assert_eq!(saro.list_conversations().unwrap().len(), GROUPS);
}
/// The creator is in its own roster from the start, with no other members: the
/// roster always includes self.
#[test]
fn group_creator_is_in_own_roster() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, saro_addr) = create_test_client(bus.clone(), reg.clone());
let convo_id = saro
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
let roster = saro.group_members(&convo_id).expect("group_members");
let accounts: Vec<Option<&str>> = roster
.iter()
.map(|m| m.account.as_ref().map(|a| a.as_str()))
.collect();
assert_eq!(accounts, vec![Some(saro_addr.as_str())]);
}
/// An invited member joins the roster immediately, flagged pending: the add is
/// staged as a proposal, so the invitee is not a member until the group commits.
#[test]
fn invited_member_is_pending_until_the_group_commits() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
// A commit window far longer than the assertions below, so the add provably
// cannot merge while they run.
let deferred_commit = GroupV2Config {
commit_inactivity_duration: Duration::from_secs(30),
..fast_group_v2_config()
};
let (mut saro, _saro_events, saro_addr) =
create_test_client_with(bus.clone(), reg.clone(), deferred_commit);
let (_raya, _raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let convo_id = saro
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
saro.add_group_members(&convo_id, &[&raya_addr])
.expect("saro invites raya");
let roster = saro.group_members(&convo_id).expect("group_members");
let accounts = |pending: bool| -> Vec<&str> {
roster
.iter()
.filter(|m| m.pending == pending)
.filter_map(|m| m.account.as_ref().map(|a| a.as_str()))
.collect()
};
assert_eq!(accounts(false), vec![saro_addr.as_str()]);
assert_eq!(accounts(true), vec![raya_addr.as_str()]);
}
/// The pending flag is transient: once the group commits the add, the invitee
/// is an ordinary roster member and nothing is left pending. The joiner, which
/// invited nobody, never reports a pending member at all: the flag is local to
/// the client that sent the invite.
#[test]
fn pending_clears_once_the_add_commits() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, saro_addr) = create_test_client(bus.clone(), reg.clone());
let (mut raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let convo_id = saro
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
saro.add_group_members(&convo_id, &[&raya_addr])
.expect("saro invites raya");
let raya_convo_id = wait_for_group_started(&raya_events, "raya ConversationStarted");
wait_for_members(&mut saro, &convo_id, &[&saro_addr, &raya_addr]);
let roster = saro.group_members(&convo_id).expect("group_members");
assert!(
roster.iter().all(|m| !m.pending),
"committed roster still reports a pending member: {roster:?}"
);
let joiner_roster = raya
.group_members(&raya_convo_id)
.expect("joiner group_members");
assert!(
joiner_roster.iter().all(|m| !m.pending),
"joiner reports a pending member it never invited: {joiner_roster:?}"
);
}
/// A batch add is validated before any member is proposed: a member whose
/// account is endorsed in the directory but whose device registered no key
/// package fails the whole call, the resolvable member in the same batch is
/// not invited, and the group keeps working.
#[test]
fn add_batch_with_missing_key_package_invites_no_one() {
let bus = MessageBus::default();
let mut reg = EphemeralRegistry::new();
let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone());
let (_raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let (_pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone());
// Ghost: its account endorses a device in the directory, but that device
// never registered a key package (no client was built for it).
let ghost_account = TestLogosAccount::new();
let ghost_delegate = DelegateSigner::random();
ghost_account
.add_delegate_signer(&mut reg, ghost_delegate.public_key())
.unwrap();
let convo_id = saro
.create_group_conversation(&[&raya_addr], unnamed_group())
.expect("saro create group");
wait_for_group_started(&raya_events, "raya ConversationStarted");
saro.add_group_members(&convo_id, &[&ghost_account.address(), &pax_addr])
.expect_err("ghost has no key package");
// Pax was in the failed batch and must not have been invited.
assert!(
pax_events.recv_timeout(Duration::from_secs(1)).is_err(),
"pax must not join from a failed batch"
);
// The failed add left the group functional.
saro.send_message(&convo_id, b"still alive").unwrap();
wait_for_message(&raya_events, b"still alive");
}
/// Group membership is resolved through the account directory, so inviting an
/// address whose account never published a bundle fails at resolution — on
/// create and on add alike.
#[test]
fn group_invite_of_unpublished_account_is_an_error() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone());
let unpublished = TestLogosAccount::new();
let err = saro
.create_group_conversation(&[&unpublished.address()], unnamed_group())
.expect_err("no bundle published for the account");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
let convo_id = saro
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
let err = saro
.add_group_members(&convo_id, &[&unpublished.address()])
.expect_err("no bundle published for the account");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
}
/// A group's name and description are set at creation and reach every joiner in
/// the welcome: the creator reads them back, and a joiner reads the same values
/// once its conversation starts.
#[test]
fn group_metadata_reaches_joiners() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone());
let (raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let convo_id = saro
.create_group_conversation(
&[&raya_addr],
GroupMetadata::new("Book Club", "Weekly reads"),
)
.expect("saro create group");
let meta = saro.group_metadata(&convo_id).expect("creator metadata");
assert_eq!(meta.name, "Book Club");
assert_eq!(meta.desc, "Weekly reads");
let raya_convo_id = wait_for_group_started(&raya_events, "raya ConversationStarted");
let meta = raya
.group_metadata(&raya_convo_id)
.expect("joiner metadata");
assert_eq!(meta.name, "Book Club");
assert_eq!(meta.desc, "Weekly reads");
}
/// A group created without a name or description reports empty metadata rather
/// than failing: both fields are optional.
#[test]
fn group_metadata_defaults_to_empty() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone());
let convo_id = saro
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
let meta = saro.group_metadata(&convo_id).expect("creator metadata");
assert_eq!(meta.name, "");
assert_eq!(meta.desc, "");
}