libchat/crates/generic-chat/tests/saro_and_raya.rs
osmaczko e7e122b0cc
feat: GroupV2 through the threaded client, group roster, registry retry (#167)
* feat: expose GroupV2 through the threaded client

GroupV2 (de-mls) conversations were reachable only from Core, and every
conversation ran the hardcoded millisecond timer profile in group_v2.rs
(20-150 ms freeze/consensus windows), which cannot survive real network
latency. Make them reachable through ChatClient (as DirectV1 already is), with
timing that holds up over a real network.

- ChatClient::create_group_conversation(accounts) and
  add_group_members(convo_id, accounts): resolve each account address to
  its endorsed signer ids through the client-held directory, the same
  resolution create_direct_conversation uses, and drive
  Core::create_group_convo / group_add_member.
- GroupV2 timing/policy is injectable: ServiceContext carries a
  de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting
  to the de-mls library defaults; Core::set_group_v2_config and the
  builder's group_v2_config setter override it. The creator's phase
  durations reach joiners inside the welcome's ConversationSync, so the
  group runs the creator's phase timing.
- GroupV2Convo::add_member validates every member's key package before
  proposing any add, skips members de-mls would silently not propose
  (self, already in the group) instead of stranding a pending invite,
  and flushes opened proposals even on a mid-batch failure, so a failed
  batch cannot invite members behind the caller's back.
- The millisecond test profile moves into the test harnesses
  (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs).
- New client-level tests: three accounts on the in-process transport
  create a group, a non-creator adds the third member, and messages fan
  out with directory-verified senders; a batch containing a member with
  no key package fails without inviting anyone.

* fix: class inbound DirectV1 joins as Private, not Group

The joiner of a DirectV1 (pairwise) conversation received it classed as
Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for
every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2
welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1
invite surfaced to the display layer as a group. ConversationClass is
documented as stable across protocol versions of the same conversation
shape, and DirectV1 is the pairwise shape, so its joiner must see Private.

- InboxV2::handle_frame returns the class alongside the convo:
  InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private,
  InviteType::GroupV2 yields Group.
- dispatch_to_inbox2 propagates that class instead of hardcoding Group.
- direct_v1_by_account_address asserts the joiner sees Private.

* feat: expose a group's roster, deduped to one entry per account

The display layer needs a group's membership, but nothing exposed it:
de-mls holds the authoritative roster (MLS group state) with no public
accessor, and members added by other members stay invisible until they
send a message. Rebuilding the roster from observed messages would fork
state the crypto layer owns and be wrong exactly when a group grows.

- GroupConvo::members() returns each member's hex-encoded MLS
  leaf-credential content, self included. GroupV2Convo delegates to
  de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls
  leaves.
- Core::group_members(convo_id) mirrors group_add_member's dispatch: a
  cached group yields its members, a direct conversation is an
  UnsupportedFunction, otherwise the group is loaded.
- ChatClient::group_members returns Vec<GroupMember>, resolving each
  member's account claim through the directory. A member whose account
  claim is unconfirmable is listed by device with account None rather
  than dropped: it is cryptographically in the group, only the account
  claim is unproven. The credential parsing decode_sender did is
  factored into parse_credential and shared by both, leaving
  decode_sender's stricter drop semantics for message senders unchanged.
- Because resolve_device_ids fans an account out to every endorsed
  device, an account whose devices all join surfaced once per device;
  group_members dedups by account, keeping the first-seen device as the
  account's representative. Members with no confirmed account stay
  individual, keyed by their unique device key.
- Unit tests cover the tolerant-vs-drop split and the per-account dedup;
  the three-member group integration test asserts the roster converges
  after create and after each add, and a solo group lists only its
  creator.

* feat: retry the registry on transient 5xx with backoff and jitter

The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds.

* fix: mark InboxV2 key package last-resort so members can join multiple groups

A key package's init key is one-time-use: openmls deletes it after the first
welcome that consumes it. Each installation registers a single key package, so a
second group inviting the same member found no matching key package and rejected
the welcome with "welcome not addressed to this member", the flaky group add.

Mark the InboxV2 key package as last-resort (and advertise the extension in the
leaf capabilities, which key-package validation requires) so openmls retains the
init key, letting one key package admit an installation to any number of groups.

This reuses one init key for every join, trading per-join forward secrecy for
membership that just works. A TODO at the publish site tracks the intended
one-time key-package pool (the registry pops one per fetch, the client
replenishes) with last-resort as the exhaustion fallback (#169).

Add regression tests: a member joining two groups (core harness) and two peers
invited to several groups over the threaded client.

* fix: dedup list_conversations across the store and the in-memory cache

A DirectV1 join persists its conversation to the store and also caches it in
memory, so list_conversations saw it twice. It deduped with Vec::dedup, which
only drops consecutive repeats, over cached_convos' nondeterministic HashMap
order, so the duplicate survived whenever another cached conversation fell
between the two copies. list_conversations then intermittently returned a
conversation twice, and a consumer counting conversations (e.g. checking that a
peer joined a group while a direct chat already existed) saw a flaky count.

Dedup through a set so a conversation held in both stores is listed once
regardless of iteration order.

Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse
across conversation types.

* fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites

Both create_group_convo_v2 and group_add_member funnel through
GroupV2Convo::add_member, so a duplicate signer (an account that resolves
to the same signer twice, or a repeated account) cost a redundant
key-package fetch and a second Add proposal. The existing guard skipped
only self and already-committed members, which a within-batch duplicate
escapes because add_member opens a proposal the committed roster does not
yet reflect, stranding a pending_invite that can later fire a spurious
duplicate welcome.

Dedup the requested signers before fetching, and guard the add loop with a
membership set seeded from the roster and self, hoisting the per-iteration
members() call out of the loop.

* docs: correct the retry-budget and group-add doc comments

The retry-budget comment claimed the ~20s init IPC budget held even at the
worst-case sum, but that only holds on the load-shed path where each retry
returns fast; a fully unreachable registry costs up to MAX_RETRIES times
the reqwest timeout, which no retry budget can rescue. State both.

Reword add_group_members to name the proposal, commit, and welcome flow
rather than the unexplained "once the add commits".

* chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP)

Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on
`self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the
RefCell Ref guard and dangle the returned reference, so the lint is a false
positive here. CI tracks floating stable (`rustup update stable`), so this is
pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00

396 lines
14 KiB
Rust

use std::time::Duration;
use components::EphemeralRegistry;
use crossbeam_channel::{Receiver, Sender};
use crypto::Ed25519VerifyingKey;
use logos_account::TestLogosAccount;
use logos_generic_chat::{
AddressedEnvelope, ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner,
DeliveryService, Event, InProcessDelivery, MessageBus, Transport,
};
/// Publish a signed device bundle endorsing `device` as a device of `account`,
/// so a receiver can verify the sender's account → device mapping.
fn publish_device_bundle(
reg: &mut EphemeralRegistry,
account: &TestLogosAccount,
device: &Ed25519VerifyingKey,
) {
account.add_delegate_signer(reg, device).unwrap();
}
/// 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.
#[allow(clippy::type_complexity)]
fn create_test_client(
message_bus: MessageBus,
mut reg: EphemeralRegistry,
) -> Result<
(
ChatClient<InProcessDelivery, EphemeralRegistry, libchat::ChatStorage>,
Receiver<Event>,
),
logos_generic_chat::ClientError,
> {
let account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
publish_device_bundle(&mut reg, &account, delegate.public_key());
let d = InProcessDelivery::new(message_bus);
ChatClientBuilder::new(account.address())
.ident(delegate)
.transport(d)
.registration(reg)
.build()
}
/// Block until the next event arrives and matches; panic on timeout/mismatch.
fn expect_event<F, T>(events: &Receiver<Event>, label: &str, mut f: F) -> T
where
F: FnMut(Event) -> Result<T, Event>,
{
let event = events
.recv_timeout(Duration::from_secs(5))
.unwrap_or_else(|_| panic!("timed out waiting for {label}"));
f(event).unwrap_or_else(|other| panic!("expected {label}, got {other:?}"))
}
#[test]
fn direct_v1_integration() {
let bus = MessageBus::default();
let reg_service = EphemeralRegistry::new();
let (mut saro, _saro_events) =
create_test_client(bus.clone(), reg_service.clone()).expect("client create");
let (raya, raya_events) =
create_test_client(bus.clone(), reg_service.clone()).expect("client create");
let convo_id = saro.create_direct_conversation(raya.addr()).unwrap();
// The invite payload yields ConversationStarted then MessageReceived.
expect_event(&raya_events, "ConversationStarted", |e| match e {
Event::ConversationStarted { convo_id, .. } => Ok(convo_id),
other => Err(other),
});
saro.send_message(&convo_id, b"Hey from saro")
.expect("payload mismatch");
expect_event(&raya_events, "MessageReceived", |e| match e {
Event::MessageReceived { content, .. } => {
assert_eq!(content.as_slice(), b"Hey from saro");
Ok(())
}
other => Err(other),
});
}
#[test]
fn direct_v1_standalone_integration() {
let bus = MessageBus::default();
let mut reg_service = EphemeralRegistry::new();
// Create accounts and delegates, and publish device bundles so the
// receiver can verify the account → device mapping carried in the
// sender's credential.
let saro_account = TestLogosAccount::new();
let saro_account_id = saro_account.address();
let saro_delegate = DelegateSigner::random();
let saro_device_id = hex::encode(saro_delegate.public_key().as_ref());
publish_device_bundle(&mut reg_service, &saro_account, saro_delegate.public_key());
// Build saro's client with its account so its outbound messages carry a
// credential the receiver can verify against the published bundle.
let (mut saro, _saro_events) = ChatClientBuilder::new(saro_account_id.clone())
.ident(saro_delegate)
.transport(InProcessDelivery::new(bus.clone()))
.registration(reg_service.clone())
.build()
.expect("client create");
let (raya, raya_events) =
create_test_client(bus.clone(), reg_service.clone()).expect("client create");
let raya_addr = raya.addr();
let convo_id = saro.create_direct_conversation(raya_addr).unwrap();
// The invite payload yields ConversationStarted then MessageReceived.
expect_event(&raya_events, "ConversationStarted", |e| match e {
Event::ConversationStarted { convo_id, .. } => Ok(convo_id),
other => Err(other),
});
saro.send_message(&convo_id, b"Hey from saro")
.expect("payload mismatch");
expect_event(&raya_events, "MessageReceived", |e| match e {
Event::MessageReceived {
content, sender, ..
} => {
assert_eq!(content.as_slice(), b"Hey from saro");
// saro associated an account and published a matching bundle, so the
// sender surfaces with a verified account and its device.
assert_eq!(
sender.account.as_ref().map(|a| a.as_str()),
Some(saro_account_id.as_str())
);
assert_eq!(sender.local_identity.as_str(), saro_device_id.as_str());
Ok(())
}
other => Err(other),
});
}
/// A peer is reachable by its *account address* alone: the initiator resolves
/// the account to its signer ids through the directory (client layer), fetches
/// each signer's key package, and the Welcome arrives on the signer-scoped
/// inbox. The registry keys key packages by device id (hex verifying key),
/// exactly like the deployed HTTP registry.
#[test]
fn direct_v1_by_account_address() {
let bus = MessageBus::default();
let mut reg_service = EphemeralRegistry::new();
let raya_account = TestLogosAccount::new();
let raya_account_addr = raya_account.address();
let raya_delegate = DelegateSigner::random();
publish_device_bundle(&mut reg_service, &raya_account, raya_delegate.public_key());
let (mut raya, raya_events) = ChatClientBuilder::new(raya_account_addr.clone())
.ident(raya_delegate)
.transport(InProcessDelivery::new(bus.clone()))
.registration(reg_service.clone())
.build()
.expect("client create");
let (mut saro, saro_events) =
create_test_client(bus.clone(), reg_service.clone()).expect("client create");
// Raya's shared address is her account address, not her signer id.
assert_eq!(raya.addr(), raya_account_addr.as_str());
let convo_id = saro.create_direct_conversation(&raya_account_addr).unwrap();
// DirectV1 is the pairwise shape, so the joiner sees it classed Private even
// though its welcome rides the InboxV2 (GroupV1 invite) path.
let raya_convo_id = expect_event(&raya_events, "ConversationStarted", |e| match e {
Event::ConversationStarted { convo_id, class } => {
assert_eq!(class, ConversationClass::Private);
Ok(convo_id)
}
other => Err(other),
});
saro.send_message(&convo_id, b"hello raya").unwrap();
expect_event(&raya_events, "MessageReceived", |e| match e {
Event::MessageReceived { content, .. } => {
assert_eq!(content.as_slice(), b"hello raya");
Ok(())
}
other => Err(other),
});
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
expect_event(&saro_events, "MessageReceived", |e| match e {
Event::MessageReceived {
content, sender, ..
} => {
assert_eq!(content.as_slice(), b"hi saro");
// raya's bundle endorses her delegate, so her sender surfaces with
// the verified account.
assert_eq!(
sender.account.as_ref().map(|a| a.as_str()),
Some(raya_account_addr.as_str())
);
Ok(())
}
other => Err(other),
});
}
#[test]
fn saro_raya_message_exchange() {
let bus = MessageBus::default();
let reg_service = EphemeralRegistry::new();
let (mut saro, saro_events) =
create_test_client(bus.clone(), reg_service.clone()).expect("client create");
let (mut raya, raya_events) =
create_test_client(bus.clone(), reg_service.clone()).expect("client create");
let saro_convo_id = saro
.create_direct_conversation(raya.addr())
.expect("convo create");
// Wait for raya to process the Welcome and subscribe to the convo delivery
// address before saro sends — MessageBus only fans out to current subscribers,
// so a message sent before raya subscribes would be silently dropped.
let raya_convo_id = expect_event(&raya_events, "ConversationStarted", |e| match e {
Event::ConversationStarted { convo_id, .. } => Ok(convo_id),
other => Err(other),
});
saro.send_message(&saro_convo_id, b"hello raya").unwrap();
expect_event(&raya_events, "MessageReceived", |e| match e {
Event::MessageReceived {
convo_id,
content,
sender,
} => {
assert_eq!(convo_id, raya_convo_id);
assert_eq!(content.as_slice(), b"hello raya");
// saro's account published a bundle endorsing its delegate, so the
// sender surfaces a verified account.
assert!(sender.account.is_some());
assert!(!sender.local_identity.as_str().is_empty());
Ok(())
}
other => Err(other),
});
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
expect_event(&saro_events, "MessageReceived", |e| match e {
Event::MessageReceived { content, .. } => {
assert_eq!(content.as_slice(), b"hi saro");
Ok(())
}
other => Err(other),
});
for i in 0u8..5 {
let msg = format!("msg {i}");
saro.send_message(&saro_convo_id, msg.as_bytes()).unwrap();
expect_event(
&raya_events,
&format!("MessageReceived(msg {i})"),
|e| match e {
Event::MessageReceived { content, .. } => {
assert_eq!(content.as_slice(), msg.as_bytes());
Ok(())
}
other => Err(other),
},
);
let reply = format!("reply {i}");
raya.send_message(&raya_convo_id, reply.as_bytes()).unwrap();
expect_event(
&saro_events,
&format!("MessageReceived(reply {i})"),
|e| match e {
Event::MessageReceived { content, .. } => {
assert_eq!(content.as_slice(), reply.as_bytes());
Ok(())
}
other => Err(other),
},
);
}
assert_eq!(saro.list_conversations().unwrap().len(), 1);
assert_eq!(raya.list_conversations().unwrap().len(), 1);
}
#[derive(Debug)]
struct FailingDelivery {
inbound_tx: Sender<Vec<u8>>,
inbound_rx: Option<Receiver<Vec<u8>>>,
}
impl FailingDelivery {
fn new() -> Self {
let (inbound_tx, inbound_rx) = crossbeam_channel::unbounded();
Self {
inbound_tx,
inbound_rx: Some(inbound_rx),
}
}
/// A sender into this transport's inbound stream — for tests to feed the
/// worker, or to hold open so it doesn't see a disconnect.
fn inbound_sender(&self) -> Sender<Vec<u8>> {
self.inbound_tx.clone()
}
}
impl DeliveryService for FailingDelivery {
type Error = &'static str;
fn publish(&mut self, _: AddressedEnvelope) -> Result<(), Self::Error> {
Err("simulated transport failure")
}
fn subscribe(&mut self, _: &str) -> Result<(), Self::Error> {
Ok(())
}
}
impl Transport for FailingDelivery {
fn inbound(&mut self) -> Receiver<Vec<u8>> {
self.inbound_rx
.take()
.expect("FailingDelivery::inbound called more than once")
}
}
#[test]
fn dropping_client_shuts_down_worker() {
let (client, events) =
create_test_client(MessageBus::default(), EphemeralRegistry::new()).expect("client create");
drop(client);
// Drop joins the worker; once joined its Sender<Event> is gone, so recv
// reports the channel as disconnected.
let res = events.recv_timeout(Duration::from_secs(5));
assert!(matches!(
res,
Err(crossbeam_channel::RecvTimeoutError::Disconnected)
));
}
#[test]
fn malformed_inbound_surfaces_as_error_event() {
// Feed the worker's inbound channel bytes that can't be decoded and assert
// it emits an InboundError instead of silently dropping the failure.
let delivery = FailingDelivery::new();
let inbound_tx = delivery.inbound_sender();
let (_client, events) = ChatClientBuilder::new(TestLogosAccount::new().address())
.transport(delivery)
.build()
.expect("client create");
inbound_tx.send(b"not a valid payload".to_vec()).unwrap();
expect_event(&events, "InboundError", |e| match e {
Event::InboundError { message } => {
assert!(!message.is_empty(), "error event should carry a message");
Ok(())
}
other => Err(other),
});
}
/// Opening a conversation by an address whose account never published a
/// device bundle fails at resolution, not with a late key-package miss.
#[test]
fn unpublished_account_address_is_an_error() {
let bus = MessageBus::default();
let reg_service = EphemeralRegistry::new();
let (mut saro, _saro_events) =
create_test_client(bus.clone(), reg_service.clone()).expect("client create");
let unpublished = TestLogosAccount::new();
let err = saro
.create_direct_conversation(&unpublished.address())
.expect_err("no bundle published for the account");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
let err = saro
.create_direct_conversation("not-an-account-address")
.expect_err("not an account key");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
}